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.

  • Frontend
  • $pinia-state
  • 205 lines
  • 2 files
  • ~1,900 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- pinia-state

Installs to ~/.agents/skills/pinia-state/ for every project on this machine.

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.

~/.agents/skills/pinia-state/SKILL.md

--- name: pinia-state description: Designs, writes and tests Pinia stores for Vue 3 and Nuxt - setup stores versus option stores, storeToRefs, getters, async actions with error state,… license: MIT ---

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

  1. Read AGENTS.md, package.json (pinia 2.x supports Vue 2 and 3; 3.x is Vue 3 only) and where createPinia() is installed (main.ts, or @pinia/nuxt in nuxt.config.ts).
  2. List existing stores (src/stores/, stores/ in Nuxt). Match their style: if the repo uses setup stores, write setup stores.
  3. Check for plugins: pinia-plugin-persistedstate, custom pinia.use(...).

What belongs in a store

StateWhere
Used by one component or its childrenComponent 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 flagsStore
Cart, multi-step wizard spanning routesStore
Form draftsComponent 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.
  • $reset exists 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 storeToRefs copies 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 after app.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 with await callOnce('cart', () => cart.load()) or useAsyncData so 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.

VuexPinia
module with namespaced: trueone store per module, id = old namespace
statestate / ref
gettersgetters / computed
mutations + actionsactions only (mutate state directly)
commit('x', p) / dispatch('y', p)store.x(p)
mapState, mapGettersstoreToRefs (or mapState from pinia for Options API)
rootStateimport 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; pass stubActions: false to run them.
  • Getters are computed from initialState; override with cart.count = 99 in tests (writable in testing pinia only) when you need a specific value.
  • Pass createSpy: vi.fn unless 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.yamlgenerated from this page

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem