Stores working together
Use one store inside another, call stores in the right place, and change or observe state in bulk with $patch(), $subscribe() and $onAction().
What you'll learn
When you finish, you can do these.- 1Make a cart store read a user store's discount
- 2Choose a safe place to call another store
- 3Group changes with $patch(), watch them with $subscribe()
- 4Spot and fix a store that reads another too early
A shop app built with Pinia, Vue's official store library, keeps its shared data, its state, in stores that any component can read and change. One store holds the signed-in user, another the cart. Members get 10% off, so the cart's total depends on the user. If every component that shows the total worked out the discount, the rule would be copied into each of them.
The Code tab holds that shop: a user store in user.ts, a cart store in cart.ts and a page, App.vue. The cart store reads the user store itself, so every component gets the discounted total. The page also adds items in one step and reports each change. You need one idea from the storeToRefs lesson: a number copied out of a store into a variable never changes again, so read values through the store.
One store reads another
The cart store gets the user's discount by calling the user store itself. Both are setup stores, as in the Setup stores lesson: refs (boxes that hold a value) for state, computed values for getters and plain functions for actions.
The user store, in user.ts, has a discount getter: 0.1 (10%) for a member, 0 for a guest. Its signIn() action, behind the Sign in button, makes the user a member.
The cart store, in cart.ts, calls useUserStore() at the top of its setup, the way a component does:
const user = useUserStore() // one store using another
const items = ref<{ name: string; price: number }[]>([])
const subtotal = computed(() => items.value.reduce((sum, i) => sum + i.price, 0))
const total = computed(() => Math.round(subtotal.value * (1 - user.discount)))
total takes the discount off the subtotal, and because it reads user.discount inside its computed, Vue recalculates it whenever the discount changes. In the preview, click Add two with $patch, which adds a $6 sticker and a $14 mug, then click Sign in: discount becomes 0.1 and the page shows $18 instead of $20.
- signIn()
- user.discount
- cart.total getter
- template
One more getter in cart.ts takes an argument: countOf counts the items with a given name. A computed takes no arguments, so countOf is a computed that returns a function:
const countOf = computed(() => (name: string) => items.value.filter(i => i.name === name).length)
App.vue calls cart.countOf('Sticker') for its Stickers count. Only the outer computed is cached (kept for reuse): each call to the function it returns filters the whole list again.
Where to call another store
Where you call a store's function, such as useUserStore(), matters: at that moment Pinia works out which app the store belongs to. A component's setup and the top of a store's setup are safe. Three places need care.
- Two stores that use each other. Each may call the other's function at the top of its setup, but neither may read the other's state there, where the other store may still be half built. Read it inside a computed or an action, which runs later.
- An async action, one that waits for a server with
await. CalluseUserStore()before the firstawait. In the browser a later call happens to work, but when a server builds the page for each visitor, code after anawaitcan run during another visitor's request and get that visitor's store. - Outside components, in a router guard (a check before a page change) or a helper file. Call the store inside the guard or function, never at the top of the file, which can run before the app installs Pinia.
$patch, $subscribe and $onAction
Every store also has built-in functions whose names start with $. These three change state in bulk or report changes.
$patch() applies several changes as one. Give it an object to merge into the state, or a function that receives the state, which is easier for arrays. addTwo in App.vue, the function behind the Add two with $patch button, passes a function:
cart.$patch((state) => {
state.items.push({ name: 'Sticker', price: 6 }, { name: 'Mug', price: 14 })
})
One click adds both items: the Items count goes up by two.
$subscribe() runs a callback, a function you hand it, after the store's state changes. The callback receives a mutation, which describes the change, and the new state. App.vue writes the report into lastChange, a ref shown on the page:
cart.$subscribe((mutation, state) => {
lastChange.value = `${mutation.type}, cart has ${state.items.length}`
})
mutation.type is 'direct' for ordinary assignments and pushes, and 'patch object' or 'patch function' for a $patch(). On an empty cart, click Add a sticker and the page reads "last change: direct, cart has 1". Click Add two with $patch and it reads "patch function, cart has 3".
Changes made in the same moment are reported together, so two pushes in one click give one 'direct' call.
A subscription made in a component ends when the component unmounts (leaves the page). Pass { detached: true } as the second argument to keep it.
$onAction() watches actions instead: its callback runs before each action, with the action's name and its arguments in args. Pass true as its second argument to keep it.
What goes wrong
Both mistakes read another store too early, at the top of a store's setup. For the first, imagine adding two lines to user.ts: the user store uses the cart store, which already uses the user store, and reads the cart's items right away:
const cart = useCartStore()
const hasItems = ref(cart.items.length > 0)
App.vue calls useCartStore(), whose setup calls useUserStore(), whose setup calls useCartStore() again. Pinia hands back the cart it is still building, which has no items yet, and the page goes blank with Cannot read properties of undefined (reading 'length'). Try this one in a project: the playground cannot load two files that import each other. The fix reads the cart inside a computed:
const cart = useCartStore()
const hasItems = computed(() => cart.items.length > 0)
The computed reads cart.items only when hasItems is used, and by then both stores are built.
The second mistake copies a value out of the user store when the cart store is created:
const discount = user.discount // a number, copied once
const total = computed(() => Math.round(subtotal.value * (1 - discount)))
Click Add two with $patch, then Sign in: the total stays at $20. discount was copied while the user was a guest, and a number copied out of a store never changes again. The fix reads through the store inside the computed:
const total = computed(() => Math.round(subtotal.value * (1 - user.discount)))
Signing in now brings the total to $18.
What to remember
- A store uses another by calling its
use…Store()function inside its own setup, and a getter that reads the other store stays reactive. - In an async action, call other stores before the first
await. Outside components, call them inside the guard or function that needs them. $patch()groups several changes into one, and$subscribe()reports it once as'patch function'or'patch object'.- A
$subscribe()or$onAction()callback set up in a component stops when the component unmounts. To keep it, pass{ detached: true }to$subscribe(), ortrueto$onAction(), as the second argument. - Read another store's state inside a computed or an action, never at the top of setup. There, two stores that use each other crash, and a copied value never changes.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add a getter that reads the user. In
cart.ts, addconst shipping = computed(() => (user.isMember ? 0 : 4)), changetotaltoMath.round(subtotal.value * (1 - user.discount)) + shipping.value, and returnshipping. Showshipping ${{ cart.shipping }}in App. Add one sticker: shipping $4, total $10. Sign in: shipping $0, total $5. - Count actions on screen. In App, add
const calls = ref(0)andcart.$onAction(() => { calls.value++ }), and show{{ calls }} action calls. Add a sticker raises the number by one. Add two with $patch does not, because$patch()is not an action. - Replace
$patch()with two plain changes. InaddTwo, writecart.items.push({ name: 'Sticker', price: 6 })andcart.items.push({ name: 'Mug', price: 14 })instead. Click Add two: the last change now reads direct instead of patch function.