Skip to content
TutorialPinia

Stores that use other stores

A Pinia store is a function, so one store can call another. What matters is where in the store you call it, and where you read its state.

vuejs.school team19 Aug 20256 min readPinia 3.0
cartitemstotaladd()refsRef<items>Ref<total>

Most apps end up with a handful of stores, and sooner or later one needs another. The cart needs to know whether the shopper is a member to apply a discount. Checkout needs to record the order against the user.

The cart store can call useUserStore() just as a component does. What needs care is when it reads the other store's state: two stores that use each other can crash a page if one reads the other while they are being created. The examples are Pinia 3 setup stores (a store written as one function with ref and computed, like a component's <script setup>), and they end with tests for the pair.

A cart store that reads the user store

Start with the user store. It holds the shopper's name and plan, and a getter (a computed) that turns the plan into a discount. Nothing here knows about the cart.

src/stores/user.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'

export type Plan = 'free' | 'member' | 'gold'

export const useUserStore = defineStore('user', () => {
  const name = ref('')
  const plan = ref<Plan>('free')
  const orders = ref(0)

  const discount = computed(() => {
    if (plan.value === 'gold') return 0.2
    if (plan.value === 'member') return 0.1
    return 0
  })

  function recordOrder() {
    orders.value++
  }

  return { name, plan, orders, discount, recordOrder }
})

The cart store is where the two meet. It imports useUserStore like any other module and calls it once at the top of its setup function. Pinia returns the same user store instance that components get, so user.discount here is the same reactive value the page header shows.

src/stores/cart.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useUserStore } from './user'

export interface CartItem {
  id: number
  title: string
  price: number
  qty: number
}

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

  const subtotal = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.qty, 0),
  )
  const total = computed(() => subtotal.value * (1 - user.discount))

  function add(item: Omit<CartItem, 'qty'>) {
    const found = items.value.find(i => i.id === item.id)
    if (found) found.qty++
    else items.value.push({ ...item, qty: 1 })
  }

  function clear() {
    items.value = []
  }

  return { items, subtotal, total, add, clear }
})

Two details. user is the store instance, not a ref, so you read user.discount without .value; Pinia unwraps the refs a setup store returns. And you do not need storeToRefs() inside a store. That helper is for destructuring in components. Here you keep the whole instance and read properties off it when the computed runs.

Getters that depend on another store

total depends on the user store, and it works because a computed is lazy. Its function does not run when the cart store is created. The first time a component renders {{ cart.total }}, the computed reads subtotal and user.discount, and from then on it recalculates when either changes. Upgrade the shopper to gold and every cart total on the page updates, with no watcher and no event.

A getter that takes an argument, such as the price of one cart row, is a function instead. Put it inside the cart store's setup function and return it with the rest:

const priceFor = (item: CartItem) =>
  Math.round(item.price * item.qty * (1 - user.discount) * 100) / 100

Call it from a template as cart.priceFor(item). Pinia treats every function a setup store returns as an action, so priceFor is really an action that returns a value. It is not cached like a computed, but it reads live state, which is usually what you want for a per-row figure.

A checkout action that calls both stores

Checkout has to send the order, tell the user store about it, and empty the cart. That is an action on the cart store that calls an action on the user store. It goes inside the cart store's setup function, after clear():

src/stores/cart.ts
async function checkout() {
  const response = await fetch('/api/orders', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ items: items.value, total: total.value }),
  })
  if (!response.ok) throw new Error(`Order failed: ${response.status}`)
  user.recordOrder()
  clear()
}

Add checkout to the returned object. The order of the three steps matters: the server call first, so a failed request leaves the cart intact and the user's order count untouched. If the call throws, nothing after it runs, and the component that called cart.checkout() receives the rejection and can show it.

One rule from the Pinia docs applies to actions like this: call useOtherStore() before the first await, not after it. In a server-rendered app, code that runs after an await can lose track of which Pinia instance belongs to the current request, so the action could change another visitor's store.

Here user was captured at the top of the setup function, so checkout() is safe however many awaits it has. If you call a store inside an action instead, do it at the top, before any await.

Avoid the crash when two stores use each other

Now the user store needs the cart: signing out should empty it. The natural move is to mirror what the cart did and call useCartStore() at the top of the user store. The trouble starts when the user store also reads the cart's state right there.

Both versions below show only the lines that matter here. Keep the rest of the user store (imports, name, orders, discount, recordOrder) and return it as before: the cart still uses it.

src/stores/user.tsBreaks
import { useCartStore } from './cart'

export const useUserStore = defineStore('user', () => {
  const cart = useCartStore()
  const plan = ref<Plan>('free')

  // Runs once, during setup
  const startedWithItems = cart.items.length > 0

  return { plan, startedWithItems }
})

Open a page whose first store call is useCartStore():

  1. Pinia registers an empty cart store, then runs the cart's setup function.
  2. Its first line calls useUserStore(), so Pinia runs the user setup function too.
  3. That calls useCartStore(). Pinia does not loop forever: it hands back the cart store it registered in step 1.
  4. The cart's setup has not returned yet, so that store has no items property. The line cart.items.length throws TypeError: Cannot read properties of undefined (reading 'length'). In development the page stays blank; a production build logs the error and renders the page without the component that called useCartStore().

A page whose first call is useUserStore() does not crash. The user setup calls useCartStore(), and the cart's setup runs to the end, because it reads user.discount only inside a computed. By the time the user setup reads cart.items, the cart is complete. That is why the bug hides: the same two stores work on one page and fail on another that happens to use the cart first.

The fix is not to stop stores using each other. It is to never read another store's state while your own setup function is running. Reading inside an action or a computed happens later, when both stores are complete.

src/stores/user.tsWorks
import { useCartStore } from './cart'

export const useUserStore = defineStore('user', () => {
  const plan = ref<Plan>('free')

  const hasItems = computed(() => useCartStore().items.length > 0)

  function signOut() {
    const cart = useCartStore()
    cart.clear()
    plan.value = 'free'
  }

  return { plan, hasItems, signOut }
})

Calling useCartStore() at the top of the user store would also work, as long as nothing reads the cart there. Calling it inside the computed and the action leaves no cart variable to read by mistake during setup, and after the first call it is only a map lookup.

The two files still import each other, and that is fine for ES modules as long as neither uses the import while the module itself is loading. defineStore only stores the function; nothing calls it until the first useCartStore() or useUserStore() call.

Test a store that depends on another

A store test needs an active Pinia instance, and a fresh one per test so state cannot leak between tests. The test then uses both stores exactly as a component would.

src/stores/cart.spec.ts
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useCartStore } from './cart'
import { useUserStore } from './user'

describe('cart total', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('applies the gold discount', () => {
    const user = useUserStore()
    const cart = useCartStore()
    user.plan = 'gold'
    cart.add({ id: 1, title: 'Vue mug', price: 10 })
    expect(cart.total).toBe(8)
  })

  it('charges full price to free accounts', () => {
    const cart = useCartStore()
    cart.add({ id: 1, title: 'Vue mug', price: 10 })
    expect(cart.total).toBe(10)
  })
})

Setting user.plan straight on the instance is allowed in a setup store, and it is the simplest way to put the dependency into the state you want. You do not mock the user store. You use the real one, which also checks that the two stores agree about what a discount is.

Test a component that calls both stores

Component tests are different, because the component calls the stores itself. There you install createTestingPinia() from @pinia/testing, and two of its options matter for a store that calls another:

  • initialState sets a store's state, by its id, as soon as that store is created.
  • stubActions is true by default. It replaces every action with a spy that does nothing and returns undefined (priceFor() included), so checkout() would never reach recordOrder(). Pass stubActions: false when the test is about the real action; the actions still get wrapped in spies you can assert on.

Here is a button that pays for the cart, and a test that clicks it:

src/components/CheckoutButton.vue
<script setup lang="ts">
import { useCartStore } from '../stores/cart'

const cart = useCartStore()
</script>

<template>
  <button :disabled="!cart.items.length" @click="cart.checkout()">
    Pay {{ cart.total }}
  </button>
</template>
src/components/CheckoutButton.spec.ts
import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import { useUserStore } from '../stores/user'
import CheckoutButton from './CheckoutButton.vue'

it('records the order on the user', async () => {
  vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 201 }))

  const wrapper = mount(CheckoutButton, {
    global: {
      plugins: [
        createTestingPinia({
          createSpy: vi.fn,
          stubActions: false,
          initialState: {
            user: { plan: 'member' },
            cart: { items: [{ id: 1, title: 'Vue mug', price: 10, qty: 1 }] },
          },
        }),
      ],
    },
  })

  await wrapper.get('button').trigger('click')
  await flushPromises()

  const user = useUserStore()
  expect(user.recordOrder).toHaveBeenCalledTimes(1)
  expect(user.orders).toBe(1)
})

createSpy: vi.fn is needed unless Vitest runs with globals: true. The test seeds both stores through initialState, clicks, waits for the mocked request to settle, and checks both the spy and the real effect. If a future change makes checkout() forget to call the user store, the first assertion fails on the missing recordOrder() call.

Check it stuck3 questions from the Stores working together quiz.
Practice now
Was this helpful?

Keep reading