Pinia State skill for Codex
Small stores, reactive reads, real tests. Codex decides whether state belongs in a Pinia store at all, then writes focused setup stores that return every ref, expose getters as computed and keep side effects in actions with explicit error state. Components read through storeToRefs, SSR apps never bind a store at module scope, and every store ships with unit tests plus component tests that stub it through createTestingPinia.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- pinia-state Installs to ~/.agents/skills/pinia-state/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install pinia-state The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project pinia-state Installs to .agents/skills/pinia-state/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/pinia-state.zipunzip -o pinia-state.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) pinia-state PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $pinia-state, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs, writes and tests Pinia stores for Vue 3 and Nuxt - setup stores versus option stores, storeToRefs, getters, async actions with error state, $reset for setup stores, SSR-safe state, persistence, HMR, and unit tests with createPinia, setActivePinia and @pinia/testing createTestingPinia. Use when adding or refactoring a Pinia store, migrating from Vuex, deciding what belongs in a store, fixing lost reactivity after destructuring a store, or writing component tests that need a mocked store.
Use it when
- Adding or refactoring a Pinia store
- Moving a Vuex codebase to Pinia module by module
- Component values stop updating after destructuring a store
- Writing unit or component tests that involve stores
Use something else when
- Logic is local to one component tree and fits a composable: use vue-3-composition
- The data is server state fetched per page in Nuxt: use nuxt-4
How to check the result
Store unit tests run with a fresh Pinia per test, component tests use createTestingPinia, and vue-tsc passes; DevTools shows every state field the store returns.
The mistake it prevents
Forgetting to return a ref from a setup store, which hides it from DevTools, plugins and SSR hydration while appearing to work in the browser.
Done well means: each store owns one domain, holds only state that is truly shared,
exposes derived data as getters, keeps side effects in actions, and has unit tests that
run without mounting the app. Components read through storeToRefs and never reach into
another store’s internals.
Inspect first
- Read
AGENTS.md,package.json(pinia2.x supports Vue 2 and 3; 3.x is Vue 3 only) and wherecreatePinia()is installed (main.ts, or@pinia/nuxtinnuxt.config.ts). - List existing stores (
src/stores/,stores/in Nuxt). Match their style: if the repo uses setup stores, write setup stores. - Check for plugins:
pinia-plugin-persistedstate, custompinia.use(...).
What belongs in a store
| State | Where |
|---|---|
| Used by one component or its children | Component ref / provide |
| Server data (lists, details, pagination) | Data-fetching layer (useFetch, TanStack Query for Vue); a store only if many unrelated views mutate it |
| Session user, permissions, feature flags | Store |
| Cart, multi-step wizard spanning routes | Store |
| Form drafts | Component state; a store only if it must survive navigation |
| URL-shaped state (filters, page, tab) | Route query, not a store |
Caching server responses in a store without invalidation rules is the most common source of stale UI. Decide who refreshes it before you add it.
Setup store (default)
// stores/cart.ts
import { defineStore, acceptHMRUpdate } from "pinia";
import { ref, computed } from "vue";
export interface CartLine { sku: string; qty: number; unitPrice: number }
export const useCartStore = defineStore("cart", () => {
const lines = ref<CartLine[]>([]);
const status = ref<"idle" | "saving" | "error">("idle");
const error = ref<string | null>(null);
const count = computed(() => lines.value.reduce((n, l) => n + l.qty, 0));
const subtotal = computed(() => lines.value.reduce((s, l) => s + l.qty * l.unitPrice, 0));
function add(line: CartLine) {
const found = lines.value.find((l) => l.sku === line.sku);
if (found) found.qty += line.qty;
else lines.value.push({ ...line });
}
async function checkout(api: { submit(lines: CartLine[]): Promise<void> }) {
status.value = "saving";
error.value = null;
try {
await api.submit(lines.value);
lines.value = [];
status.value = "idle";
} catch (e) {
status.value = "error";
error.value = e instanceof Error ? e.message : "Checkout failed";
throw e;
}
}
function $reset() {
lines.value = [];
status.value = "idle";
error.value = null;
}
return { lines, status, error, count, subtotal, add, checkout, $reset };
});
if (import.meta.hot) import.meta.hot.accept(acceptHMRUpdate(useCartStore, import.meta.hot));
Setup store rules:
- Return every piece of state. Unreturned refs are invisible to DevTools, SSR serialization and plugins, and break hydration.
$resetexists automatically only on option stores; write your own in setup stores.inject(),useRoute()and other composables work inside the setup function; they run once when the store is first used.- Keep state serializable (no class instances, DOM nodes or functions in refs) if the app uses SSR or persistence.
Use an option store (state, getters, actions) when the team prefers it or for
simple stores migrated from Vuex; do not mix styles within one repo without reason.
Using stores in components
const cart = useCartStore();
const { lines, count, subtotal, status } = storeToRefs(cart); // state + getters stay reactive
const { add, checkout } = cart; // actions destructure directly
- Destructuring state without
storeToRefscopies values once and loses reactivity. - Bulk updates:
cart.$patch((s) => { s.lines.push(a, b) })produces one subscription event. - Cross-store access: call
useOtherStore()inside an action or getter, not at the top of the setup function when two stores reference each other (circular init). - Outside components (router guards, plugins), call
useXStore(pinia)with the instance, or only afterapp.use(pinia)has run.
SSR and Nuxt
- With
@pinia/nuxt, stores are auto-imported and state is serialized into the payload. Fetch inside the page withawait callOnce('cart', () => cart.load())oruseAsyncDataso the server fills it once. - Never create a store at module scope (
const cart = useCartStore()outside a function) in SSR code: it binds to the first request’s Pinia and leaks state between users. - Persistence (
pinia-plugin-persistedstate) reads from storage only on the client; expect the server render to use defaults and avoid rendering persisted values before mount, or you get hydration mismatches.
Migrating from Vuex
Move one module at a time; the two can coexist.
| Vuex | Pinia |
|---|---|
module with namespaced: true | one store per module, id = old namespace |
state | state / ref |
getters | getters / computed |
mutations + actions | actions only (mutate state directly) |
commit('x', p) / dispatch('y', p) | store.x(p) |
mapState, mapGetters | storeToRefs (or mapState from pinia for Options API) |
rootState | import and call the other store |
Testing
Unit test a store directly:
import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCartStore } from "@/stores/cart";
describe("cart store", () => {
beforeEach(() => setActivePinia(createPinia())); // fresh state per test
it("merges lines with the same sku", () => {
const cart = useCartStore();
cart.add({ sku: "A", qty: 1, unitPrice: 5 });
cart.add({ sku: "A", qty: 2, unitPrice: 5 });
expect(cart.lines).toHaveLength(1);
expect(cart.subtotal).toBe(15);
});
it("keeps lines and records the error when checkout fails", async () => {
const cart = useCartStore();
cart.add({ sku: "A", qty: 1, unitPrice: 5 });
const api = { submit: vi.fn().mockRejectedValue(new Error("card declined")) };
await expect(cart.checkout(api)).rejects.toThrow();
expect(cart.status).toBe("error");
expect(cart.lines).toHaveLength(1);
});
});
Component tests with a mocked store (@pinia/testing):
import { mount } from "@vue/test-utils";
import { createTestingPinia } from "@pinia/testing";
const wrapper = mount(CartBadge, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn, initialState: { cart: { lines: [{ sku: "A", qty: 3, unitPrice: 1 }] } } })],
},
});
const cart = useCartStore(); // after mount
expect(wrapper.text()).toContain("3");
await wrapper.find("button").trigger("click");
expect(cart.add).toHaveBeenCalled(); // actions are stubbed by default
- Actions are stubbed in
createTestingPinia; passstubActions: falseto run them. - Getters are computed from
initialState; override withcart.count = 99in tests (writable in testing pinia only) when you need a specific value. - Pass
createSpy: vi.fnunless Vitest globals are enabled.
Verification
npx vue-tsc --noEmit
npx vitest run stores/
- Vue DevTools Pinia tab: state shows all returned fields, actions appear in the timeline.
- For SSR apps, open two browsers with different users and confirm no shared state.
Output
Report each store’s id, what state it owns and why it is global, the actions with side effects, persistence settings, and the tests added.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Pinia State"
short_description: "Small stores, reactive reads, real tests"
default_prompt: "$pinia-state Create a Pinia cart store with checkout and tests" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.