← All posts
·12 min read

Claude Code with Pinia: Vue 3 State Management Patterns

Claude CodePiniaVueState Management
Claude Code with Pinia: Vue 3 State Management Patterns

Why Pinia needs its own CLAUDE.md rules

Claude Code knows Vue 3. It can scaffold a component, wire a composable, write Vitest tests. For generic Vue work, that is enough.

State management is different. Pinia replaced Vuex as the official Vue state manager in 2021, but Claude's training data contains far more Vuex patterns than Pinia patterns. Without explicit rules, Claude reaches for what it has seen most: commit-based mutations, module namespacing, mapState/mapActions helpers, and the state / getters / mutations / actions options object. All of that is Vuex API. None of it belongs in a Pinia codebase.

The gap is specific. Claude knows Pinia exists. It can write a basic defineStore. The failure modes appear in the details: generating an options store when your project uses setup stores, writing direct state mutations outside of actions, forgetting storeToRefs and losing reactivity when destructuring, or treating Pinia actions like Vuex mutations (returning nothing and mutating synchronously). In a Nuxt project, add SSR data bleed from module-level state and the wrong data fetching pattern for populating stores during server render.

A CLAUDE.md solves all of this in one file. You write the rules once; Claude applies them in every session. This guide builds that configuration layer, then covers the specific patterns that produce consistent, modern Pinia output. If you are setting up Claude Code for the first time, the Claude Code setup guide covers installation. For the broader Vue 3 conventions this builds on, Claude Code with Vue 3 is the companion guide.

The Pinia CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Pinia project, it needs to declare: which store syntax you use, how state is accessed in components, how actions work, how getters are defined, and the hard rules that prevent the most common failure modes.

# Pinia state management rules

## Setup

- Pinia 2.x, installed and registered in main.ts (or via @pinia/nuxt in Nuxt)
- All stores in src/stores/{name}.store.ts
- Auto-imported in Nuxt projects (no import needed)
- TypeScript strict mode: type all state, getters, and action params

## Store syntax (ALWAYS setup store, never options store)

Use the setup store syntax exclusively:

  export const useCartStore = defineStore('cart', () => {
    // state: refs
    const items = ref<CartItem[]>([])
    const isLoading = ref(false)

    // getters: computed
    const itemCount = computed(() => items.value.length)
    const totalPrice = computed(() =>
      items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
    )

    // actions: functions
    function addItem(item: CartItem) {
      const existing = items.value.find(i => i.id === item.id)
      if (existing) {
        existing.quantity += 1
      } else {
        items.value.push(item)
      }
    }

    async function checkout() {
      isLoading.value = true
      try {
        await $fetch('/api/checkout', { method: 'POST', body: { items: items.value } })
        items.value = []
      } finally {
        isLoading.value = false
      }
    }

    return { items: readonly(items), isLoading: readonly(isLoading), itemCount, totalPrice, addItem, checkout }
  })

## Component usage

- Call the store composable at the top of script setup: const cartStore = useCartStore()
- Use storeToRefs() to destructure reactive state and getters:
    const { items, itemCount, totalPrice } = storeToRefs(cartStore)
- Destructure actions directly (they are not reactive):
    const { addItem, checkout } = cartStore
- NEVER destructure state without storeToRefs: it loses reactivity
- NEVER mutate store state directly outside of a store action

## Hard rules

- NEVER use options store syntax (state/getters/mutations/actions object)
- NEVER write commit() or dispatch(): that is Vuex, not Pinia
- NEVER mutate state directly in a component: cartStore.items = [] is wrong
- NEVER use mapState, mapActions, mapGetters: Pinia does not need them
- ALWAYS wrap state and getters in readonly() in the store return object
- Actions can be async. Getters are computed, not async.
- Plugins (persistence, devtools) are registered once in main.ts, not per store

This template blocks the four patterns Claude generates most often without Pinia context: Vuex commit syntax, direct state mutation from components, options store structure, and the helper map functions that made sense in Vuex but are unnecessary in Pinia.

Setup stores vs options stores: why the choice matters

Pinia supports two syntaxes. The options store uses a familiar object structure. The setup store uses a function that mirrors the Composition API. Most modern Vue 3 projects that use <script setup> in components should use setup stores for consistency.

Without a CLAUDE.md rule, Claude generates whichever form it calculates is simpler for the task. On a small store, it often picks the options form. Add more complex actions and it switches to setup. The inconsistency means stores look different from each other and differ from the Composition API style in your components.

Here is what each looks like, and why the setup form is preferable when your codebase uses <script setup>:

// Options store, DO NOT USE in a script setup project
export const useUserStore = defineStore('user', {
  state: () => ({
    profile: null as User | null,
    isAuthenticated: false,
  }),
  getters: {
    displayName: (state) => state.profile?.name ?? 'Guest',
  },
  actions: {
    async login(credentials: Credentials) {
      this.profile = await $fetch('/api/auth/login', { method: 'POST', body: credentials })
      this.isAuthenticated = true
    },
    logout() {
      this.profile = null
      this.isAuthenticated = false
    }
  }
})
// Setup store, consistent with script setup convention
export const useUserStore = defineStore('user', () => {
  const profile = ref<User | null>(null)
  const isAuthenticated = ref(false)

  const displayName = computed(() => profile.value?.name ?? 'Guest')

  async function login(credentials: Credentials) {
    profile.value = await $fetch('/api/auth/login', { method: 'POST', body: credentials })
    isAuthenticated.value = true
  }

  function logout() {
    profile.value = null
    isAuthenticated.value = false
  }

  return { profile: readonly(profile), isAuthenticated: readonly(isAuthenticated), displayName, login, logout }
})

The mental model is identical to a composable. State is a ref. A getter is a computed. An action is a function. If you can write <script setup>, you already know how to write a setup store. The one-line CLAUDE.md rule "NEVER use options store syntax" is enough to lock this for every store Claude generates.

storeToRefs and the reactivity loss trap

The most common Pinia mistake Claude generates without specific guidance is destructuring store state directly:

// WRONG: reactivity lost, changes to the store do not update the template
const { items, itemCount } = useCartStore()

items is now a plain array reference. itemCount is a number. Neither is reactive. When the store changes its state, the component does not re-render because there is nothing reactive to track.

The correct pattern uses storeToRefs for state and getters, and direct destructuring only for actions:

// CORRECT: state and getters via storeToRefs, actions direct
const cartStore = useCartStore()
const { items, itemCount, totalPrice } = storeToRefs(cartStore)
const { addItem, removeItem, checkout } = cartStore

storeToRefs converts each state property and getter into a ref, preserving reactivity. Actions are plain functions and do not need wrapping.

Add an explicit example to your CLAUDE.md because the distinction between "which things go in storeToRefs" and "which go direct" is exactly the kind of detail Claude produces inconsistently without a pattern to follow:

## storeToRefs rule

  const productStore = useProductStore()

  // State and getters through storeToRefs
  const { products, filteredProducts, isLoading, error } = storeToRefs(productStore)

  // Actions destructured directly
  const { fetchProducts, deleteProduct, updateProduct } = productStore

The CLAUDE.md example acts as a one-shot pattern. Claude replicates it for every store interaction it generates.

Store-to-store communication

Real applications have stores that depend on each other. A cart store needs the user store to know if the user is authenticated before allowing checkout. An orders store needs the cart store to know what to submit. Pinia handles this cleanly because stores are just composables.

The correct pattern is to call one store's composable inside another store's action:

// src/stores/cart.store.ts
import { useUserStore } from './user.store'

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])

  async function checkout() {
    // Access another store inside an action
    const userStore = useUserStore()

    if (!userStore.isAuthenticated.value) {
      throw new Error('Must be logged in to checkout')
    }

    await $fetch('/api/orders', {
      method: 'POST',
      body: {
        items: items.value,
        userId: userStore.profile.value?.id
      }
    })

    items.value = []
  }

  return { items: readonly(items), checkout }
})

Two rules prevent the anti-patterns Claude generates here without guidance:

First, never import a store at the module level outside of a function. Pinia initialises lazily when the composable is first called. Importing the store at module load time before Pinia is set up causes a runtime error.

Second, never create circular dependencies between stores. If userStore calls cartStore and cartStore calls userStore, you have a circular import that Node's module system will silently resolve by returning an incomplete module. The symptom is a undefined is not a function error at runtime.

Add both rules to your CLAUDE.md:

## Store-to-store rules

- Import sibling stores inside the action that needs them, NOT at the module level
- NEVER create circular dependencies between stores
- Design store dependency direction: auth -> user -> cart -> orders (unidirectional)

SSR patterns for Nuxt 3

When Pinia runs in Nuxt 3, there is one critical constraint that does not exist in a Vue SPA: state that lives outside of a Pinia store (in a module-level variable) is shared across all SSR requests. Two users hit your server simultaneously, and both see the first user's state. This is a data bleed.

Pinia itself solves this for store state. The @pinia/nuxt module creates a fresh Pinia instance per request. Your store state is scoped to the request. The mistake Claude makes without Nuxt-specific rules is populating that store state in the wrong place.

The correct pattern for loading store data during SSR is to wrap the store action in useAsyncData:

// pages/products/index.vue
<script setup lang="ts">
const productStore = useProductStore()
const { products, isLoading } = storeToRefs(productStore)

// CORRECT: useAsyncData serialises the result into the hydration payload
// The store state is populated on the server and sent to the client
// No second fetch occurs on mount
await useAsyncData('products-index', () => productStore.fetchProducts())
</script>

Without the useAsyncData wrapper, here is what happens: the store action runs on the server, the store state is populated, SSR completes. Nuxt sends HTML to the client. The client hydrates. Pinia sees no data in the hydration payload for this store (because the fetch was not wrapped in useAsyncData) and the store initialises empty. The component mounts with empty state and triggers a second fetch. The user sees a flash of empty content.

With the wrapper, Nuxt serialises the Pinia store state into the HTML payload alongside the other useAsyncData results. The client picks it up during hydration. No second fetch.

Add the pattern explicitly to your CLAUDE.md:

## Pinia + Nuxt SSR pattern

- Populate stores in pages via: await useAsyncData('unique-key', () => store.action())
- The key must be unique across your entire application
- NEVER call store actions directly in script setup without useAsyncData wrapping
- storeToRefs() works identically in Nuxt, no difference from Vue SPA usage
- NEVER use module-level variables for state in Nuxt (shared across SSR requests)

For a full treatment of Nuxt-specific patterns including useState for simpler shared state, the rendering mode decision guide, and server route conventions, Claude Code with Nuxt covers those in depth.

Persistence plugins

Pinia does not persist state across page reloads by default. pinia-plugin-persistedstate is the standard solution. Claude generates persistence configuration inconsistently without a template: sometimes it writes a custom plugin from scratch, sometimes it generates the wrong plugin API, sometimes it adds persistence directly to the store definition without the plugin installed.

The correct setup registers the plugin once in main.ts and opts stores into persistence per store:

// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.mount('#app')
// src/stores/cart.store.ts
export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  const couponCode = ref<string | null>(null)

  // ... actions

  return { items: readonly(items), couponCode: readonly(couponCode) }
}, {
  // Persistence options object is the third argument to defineStore
  persist: {
    // Persist only items, not loading state or computed values
    pick: ['items', 'couponCode'],
    storage: localStorage,
    // Optional: custom serialisation
    serializer: {
      deserialize: JSON.parse,
      serialize: JSON.stringify,
    }
  }
})

For Nuxt, use the SSR-safe storage option from @pinia-plugin-persistedstate/nuxt:

// In a Nuxt store, with @pinia-plugin-persistedstate/nuxt installed
export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  return { items: readonly(items) }
}, {
  persist: {
    storage: piniaPluginPersistedstate.cookies(),  // SSR-safe, not localStorage
  }
})

Add both variants to your CLAUDE.md so Claude picks the right one for your environment:

## Persistence plugin

- Plugin: pinia-plugin-persistedstate
- Register once in main.ts, NOT in individual stores
- opt in per store as third argument to defineStore: { persist: { pick: ['field1', 'field2'] } }
- In Nuxt: use piniaPluginPersistedstate.cookies() for SSR-safe storage
- NEVER persist loading states, error states, or computed values

What Claude gets wrong without context

Beyond the Vuex vs Pinia confusion, three specific failure modes appear consistently when no CLAUDE.md is in place.

Direct state mutation from components. Without the rule, Claude sometimes writes this:

// WRONG: mutating store state from a component
const cartStore = useCartStore()
cartStore.items.push(newItem)  // bypasses the store's action, defeats encapsulation

The fix is always to route state changes through actions. The CLAUDE.md rule "NEVER mutate store state directly in a component" covers this.

Async getters. Pinia getters are computed values. They are synchronous by definition. Claude occasionally generates an async getter that calls an API, which either returns a Promise (not the resolved value) or throws an error at runtime.

// WRONG: async getter
const expensiveProducts = computed(async () => {
  return await $fetch('/api/products/expensive')  // returns Promise, not Product[]
})

The correct pattern is an async action that populates a state ref, and a getter that reads from that ref:

// CORRECT: async action + sync getter
const expensiveProducts = ref<Product[]>([])
const hasExpensiveProducts = computed(() => expensiveProducts.value.length > 0)

async function fetchExpensiveProducts() {
  expensiveProducts.value = await $fetch('/api/products/expensive')
}

Add one line to CLAUDE.md: "Getters are computed, not async. Async data goes in an action that sets a ref."

Missing TypeScript generics on refs. Claude occasionally generates untyped refs inside stores, which loses type safety throughout the application:

// WRONG: no generic on ref
const items = ref([])  // inferred as never[] or any[]
// CORRECT: explicit generic
const items = ref<CartItem[]>([])

The CLAUDE.md instruction "TypeScript strict mode: type all state, getters, and action params" catches this. Pairing it with a store example that shows typed refs gives Claude a concrete pattern to follow.

Building stores that Claude Code can extend

The Pinia configuration in this guide produces Claude Code output that uses setup store syntax consistently, accesses state through storeToRefs, routes mutations through actions, communicates between stores inside action scope, wraps SSR data loading in useAsyncData, and opts into persistence with the correct plugin API.

The CLAUDE.md template above is the foundation. Drop it into your project root, adjust the store directory path and any plugin specifics for your setup, and run one full store-to-component cycle to verify the output matches your conventions. Most projects need one or two additional rules after that first pass. After those are added, Claude generates store code that is consistent enough to review in seconds rather than rewrite.

For the TypeScript patterns that keep Pinia stores type-safe across the codebase, Claude Code with TypeScript covers strict mode configuration and the type-safe patterns that compose with the stores above. For the broader Vue 3 component and composable patterns that these stores feed into, Claude Code with Vue 3 is the companion. And if you want Pinia stores pre-configured for Nuxt with SSR, persistence, and DevTools rules already written, Claudify includes a production-ready Pinia CLAUDE.md template as part of its Vue skill pack.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir