Your first store
Decide when state belongs in a store, install Pinia, and share one option store between two components.
What you'll learn
When you finish, you can do these.- 1Choose between a ref, props and a store for a value
- 2Install Pinia and write a store with defineStore()
- 3Share one store between two components
- 4Spot and fix a broken getter and a store called too soon
On a shop page, a badge in the header shows how many items are in the cart, and the cart further down has buttons that add items. Vue builds the page from components, parts nested like a tree, with App at the top. The header and the cart sit in different branches. Without a store, each change climbs up to App and back down to the header, one component at a time. A store keeps the number in one place outside the tree, where any component can read and change it.
Pinia is Vue's official store library. The Code tab holds a small version of that page, cut down to one number, the count. It has the page, App.vue, with an Add one button; a badge in its header, CounterBadge.vue; and the store they share, stores/counter.ts. By the end you can read every line and add to the store yourself. You need one idea from the Reactivity basics lesson: a ref, a box that holds a value and tells Vue when it changes.
Store or not
Most state, the data that changes on a page, does not belong in a store. Pick the smallest tool that reaches every component that needs the value.
- One component uses it, like the text in a search box: a
ref()inside that component, as in the Reactivity basics lesson. - A parent and its children use it: props to hand it down and events to send changes back up, both covered in the Beginner track.
- A whole branch uses it, such as a form and its fields: the Vue functions
provide()andinject(), covered in the Advanced track. - Many components across the app read it and change it: a store, as for a cart, the signed-in user or the settings.
The diagram draws the shop page with its cart in a store. Header and Cart are siblings under App, and neither passes anything to the other: both call useCartStore() and get the same store.
Define a store
Making a store takes two steps. First, Pinia is installed once for the whole app, in main.ts, the file that starts the app. createPinia() creates the Pinia instance that holds the state of every store, and app.use() attaches it before app.mount('#app') puts the app on the page.
const app = createApp(App)
app.use(createPinia()) // once, before any component uses a store
app.mount('#app')
From then on, any component can ask Pinia for a store.
Second, the store gets its own file, made with defineStore(), Pinia's function for making a store. It takes a unique id that names the store, and the store's parts. It returns the function that components call to get the store, named by convention use plus the name plus Store. This store keeps a count, so its function is useCounterStore and its file stores/counter.ts.
This store's three parts are written as one object, which makes it an option store:
stateis a function that returns the starting data, here a count of 0. It is a function so that each app gets its own fresh copy.gettersare values worked out from the state, such asdouble, the count times two. Each receives the state as its argument and is cached: it runs again only when that state changes.actionsare methods that change the state. Inside an action,this, the JavaScript word for the object a method belongs to, is the store.
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
double: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
Nothing shows on the page until a component calls useCounterStore().
Use it from two components
Two components share the store: the shop page, App.vue, and a badge in its header, CounterBadge.vue, which App imports and places there as <CounterBadge />.
In <script setup>, App calls useCounterStore() and keeps the result as counter. The store is a reactive object, so you skip the .value that opens a ref's box: you read counter.count and counter.double and call counter.increment() like a method.
<script setup lang="ts">
import { useCounterStore } from './stores/counter'
import CounterBadge from './CounterBadge.vue'
const counter = useCounterStore()
</script>
<template>
<header>My shop <CounterBadge /></header>
<p>{{ counter.count }} × 2 = {{ counter.double }}</p>
<button @click="counter.increment()">Add one</button>
</template>
The preview shows My shop with the badge Count 0, then 0 × 2 = 0 and the Add one button.
CounterBadge.vue makes the same call in its own <script setup>, and its template prints the same count:
<template>
<span class="badge">Count {{ counter.count }}</span>
</template>
The first call creates the store. Every later call, from any component, returns that same store. Click Add one: the line turns to 1 × 2 = 2 and the badge to Count 1, although App passes no props to the badge.
Here props would work too, since the badge sits inside App. The store pays off once the badge and the Add one button sit in different branches.
What goes wrong
Two common mistakes leave the page blank with an error.
The first is a getter written as an arrow function, the => form, that uses this. An arrow function has no this of its own, so it takes the this of the file around it, which is undefined in a module, a file that uses import.
getters: {
double: () => this.count * 2,
},
The page stays blank, and the preview shows Cannot read properties of undefined (reading 'count') as soon as the template reads counter.double. Use the state argument instead.
getters: {
double: (state) => state.count * 2,
},
The page returns, showing 0 × 2 = 0.
The second calls a store at the top of a plain file, outside any function. Picture a helper that checks the cart before checkout, in its own file, checkout.ts, which is not in the Code tab.
import { useCounterStore } from './stores/counter'
const counter = useCounterStore() // runs while the file is being imported
export function canCheckout() {
return counter.count > 0
}
main.ts imports App.vue before it reaches app.use(). If App imports this helper, the store call runs during that import, before Pinia is attached, and Pinia throws an error that says "getActivePinia()" was called but there was no active Pinia. Move the call into the function, which runs later, once the app has started.
export function canCheckout() {
const counter = useCounterStore()
return counter.count > 0
}
The page loads again. Components and other stores never hit this, because they run after the app is set up.
What to remember
- Use a
ref()for one component, props for a parent and its children, and a store for state that distant components share. app.use(createPinia())runs once inmain.ts, before anything calls a store.- An option store has
state, a function returning the starting data;getters, cached values that read it through theirstateargument; andactions, methods in whichthisis the store. - Every call to
useCounterStore()returns the same store, so a change in one component shows in all of them. - Call a store inside a component, another store or a function, never at the top of a plain file.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add a reset action. In
stores/counter.ts, addreset() { this.count = 0 }underactions, and in App add<button @click="counter.reset()">Reset</button>. Click Add one a few times, then Reset: the page shows 0 × 2 = 0 and the badge says Count 0. - Change the store from the badge. In
CounterBadge.vue, add<button @click="counter.increment()">+1</button>after the count. Clicking it moves the badge and the line in App together, because both components hold the same store. - Break the getter on purpose. Change
doubletodouble: () => this.count * 2. The preview goes blank and showsCannot read properties of undefined (reading 'count'). Change it back to(state) => state.count * 2and the page returns.