Stores That Scale

One store per domain, stores calling other stores inside actions, resetting cleanly, and leaving server data to the request layer.

Syntaxstore.$reset() | store.$dispose() | useOtherStore() inside an action

A store per domain, named after it: auth, cart, settings. The id is unique across the app and is what devtools and plugins key on. Small focused stores are easier to reset and to delete than one app store that everything imports.

Stores using other stores

Call the other store function inside the action or getter that needs it, not at the top of the module. The call happens while a store is already active, and because it is lazy, two stores may import each other without a cycle at load time.

Resetting

Option stores get $reset() for free; it calls state() again and applies the result. Setup stores do not: return your own function named $reset. Signing out is the usual moment to reset, and one action that resets several stores keeps that list in one place. store.$dispose() goes further and stops the store's effects, which you rarely need outside tests.

Keeping server data out

A store is a good home for state the client owns: the signed-in session, UI preferences, a draft form, which ids are selected. It is a poor home for a copy of the server's data, because nothing in it knows when that copy went stale, and you end up hand-writing loading flags, refetching and invalidation. Store the ids; let the request layer own the bodies and their cache.

What trips people up

Two stores defined with the same id silently share one state object.

Example

Example · vue
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'

const auth = useAuthStore()

async function onSignOut() {
  await auth.signOut()
}
</script>

<template>
  <button type="button" :disabled="auth.isWorking" @click="onSignOut">
    {{ auth.isWorking ? 'Signing out' : 'Sign out' }}
  </button>
</template>

<style scoped>
button {
  font: inherit;
}
</style>

When to use it

  • Clearing the cart, drafts and session together when a user signs out.
  • Letting a checkout store read the signed-in user without importing it at module scope.
  • Deciding where a list of fetched search results should live when both a store and a fetch layer could hold it.

More examples

One action, several stores

Sign-out is the one place where several stores have to be cleared together, so the list belongs in one action rather than in a component. Calling useCartStore() inside signOut also means the cart store file can import the auth store without an import cycle.

Example · ts
// src/stores/auth.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useCartStore } from './cart'
import { useDraftStore } from './draft'

export const useAuthStore = defineStore('auth', () => {
  const userId = ref<string | null>(null)
  const isWorking = ref(false)

  async function signOut() {
    // Called here, not at module level: a store must be active.
    // Both calls sit before the await: a useStore() call after an await can
    // pick up the wrong pinia instance on the server.
    const cart = useCartStore()
    const draft = useDraftStore()

    isWorking.value = true
    try {
      await fetch('/api/session', { method: 'DELETE' })
      cart.$reset()
      draft.$reset()
      userId.value = null
    } finally {
      isWorking.value = false
    }
  }

  return { userId, isWorking, signOut }
})

A reset for a setup store

A factory function builds the initial value, and both the ref and $reset use it, so the shape is written once. Returning the object through a ref also lets $reset replace the whole thing in one assignment; a reactive() object would have to be reassigned key by key to stay reactive.

Example · ts
// src/stores/draft.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'

interface Draft {
  title: string
  body: string
}

function empty(): Draft {
  return { title: '', body: '' }
}

export const useDraftStore = defineStore('draft', () => {
  const form = ref<Draft>(empty())

  function $reset() {
    form.value = empty()
  }

  return { form, $reset }
})

const draft = useDraftStore()
draft.form.title = 'Hello'
draft.$reset()
// Output: ""
console.log(JSON.stringify(draft.form.title))

Store the ids, not the server's rows

The line to draw when a store starts filling up with API responses. The ids are a user decision and belong in shared state; the product records belong to the server and are better held by whatever already knows how to refetch and invalidate them.

Example · ts
// src/stores/wishlist.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'

// Client-owned: which product ids the user saved, and nothing else.
export const useWishlistStore = defineStore('wishlist', () => {
  const ids = ref<string[]>([])

  function toggle(id: string) {
    const at = ids.value.indexOf(id)
    if (at === -1) {
      ids.value.push(id)
      return
    }
    ids.value.splice(at, 1)
  }

  return { ids, toggle }
})

// The titles and prices behind those ids are fetched and cached by the
// request layer, so the store never holds a copy that can go stale.

Discussion

  • Be the first to comment on this lesson.