Pinia
Assignment
Martina Hostettler gets in touch from the counter.
As a volunteer at the counter, I want nothing of the previous member's requests to be visible after signing out, so that the next member at the same device only sees their own.
The volunteer suggests putting the login and the requests into stores with Pinia. The acceptance criteria are:
- The login and the requests each live in a store.
- The header, the page of a thing and the guard read the login from the store.
- After signing out, no request of the previous member is left in memory.
Learning goals
After this chapter you can
- set up Pinia in an app,
- write a setup store with state, getters and actions,
- take a store apart with
storeToRefswithout losing the connection, - use a store in another store and in a navigation guard,
- test stores and components that use stores.
Setting up Pinia
Section titled “Setting up Pinia”Pinia is the library for stores that Vue recommends. According to the Vue documentation, the Vue core team maintains it, and the older Vuex no longer gets new features. Compared with a module like the one in the last chapter, Pinia brings fixed conventions for working together and a connection to the Vue DevTools.
A create-vue project with the Pinia option already has everything it needs. Pinia is among the dependencies, an example store sits in src/stores, and main.js installs Pinia before the router.
const app = createApp(App)
app.use(createPinia())app.use(router)
app.mount('#app')Setup stores
Section titled “Setup stores”defineStore creates a store. The first argument is an id that has to be unique in the app. The second is a function like <script setup>. According to the Pinia documentation, refs in it become state, computed values become getters and functions become actions. By convention the store is called use plus a name plus Store.
App.vue
<script setup>import AppHeader from './AppHeader.vue'import ItemList from './ItemList.vue'</script>
<template> <AppHeader /> <ItemList /></template>AppHeader.vue
<script setup>import { useFavoritesStore } from './favorites.js'
// Every component that calls the function gets the same storeconst favorites = useFavoritesStore()</script>
<template> <header> <strong>Lorraine Commons</strong> <span>Favorites: {{ favorites.count }}</span> </header></template>
<style scoped>header { display: flex; justify-content: space-between; gap: 1rem;}</style>favorites.js
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
// A setup store: refs become state, computed values getters and functions actionsexport const useFavoritesStore = defineStore('favorites', () => { const ids = ref([]) const count = computed(() => ids.value.length)
function isFavorite(id) { return ids.value.includes(id) }
function toggle(id) { ids.value = isFavorite(id) ? ids.value.filter((entry) => entry !== id) : [...ids.value, id] }
// A setup store returns all of its state, otherwise Pinia and the devtools miss it return { ids, count, isFavorite, toggle }})ItemList.vue
<script setup>import { useFavoritesStore } from './favorites.js'import { things } from './things.js'
const favorites = useFavoritesStore()</script>
<template> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" :aria-pressed="favorites.isFavorite(thing.id)" @click="favorites.toggle(thing.id)">Favorite</button> </li> </ul></template>
<style scoped>[aria-pressed='true'] { font-weight: 700;}</style>main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20, availableFrom: null }, { id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null }, { id: 6, name: 'Projector', deposit: 80, availableFrom: null }, { id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },]Every component that calls useFavoritesStore() gets the same store. A setup store has to return all of its state, otherwise Pinia and the developer tools miss it, according to the documentation. That's why private state doesn't exist. Besides setup stores, Pinia knows option stores with the fields state, getters and actions, like the Options API. This course uses setup stores, because they look like <script setup> and can use composables.
Taking stores apart with storeToRefs
Section titled “Taking stores apart with storeToRefs”A store is a reactive object. Taking it apart directly gives you copies of the values, as with reactive in chapter 5.1. storeToRefs turns state and getters into refs that stay connected to the store.
App.vue
<script setup>import { storeToRefs } from 'pinia'
import { useFavoritesStore } from './favorites.js'import { things } from './things.js'
const favorites = useFavoritesStore()// Destructured directly, copiedCount keeps the number of this momentconst { count: copiedCount } = favorites// storeToRefs makes a ref of every piece of state and every getterconst { count } = storeToRefs(favorites)// Actions belong to the store and can be destructured directlyconst { toggle } = favorites</script>
<template> <p>Destructured directly: {{ copiedCount }}</p> <p>With storeToRefs: {{ count }}</p> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" @click="toggle(thing.id)">Favorite</button> </li> </ul></template>favorites.js
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
// A setup store: refs become state, computed values getters and functions actionsexport const useFavoritesStore = defineStore('favorites', () => { const ids = ref([]) const count = computed(() => ids.value.length)
function isFavorite(id) { return ids.value.includes(id) }
function toggle(id) { ids.value = isFavorite(id) ? ids.value.filter((entry) => entry !== id) : [...ids.value, id] }
// A setup store returns all of its state, otherwise Pinia and the devtools miss it return { ids, count, isFavorite, toggle }})main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20, availableFrom: null }, { id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null }, { id: 6, name: 'Projector', deposit: 80, availableFrom: null }, { id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },]Actions can be destructured directly, because according to the documentation they are bound to the store. storeToRefs leaves them out.
Stores that use other stores
Section titled “Stores that use other stores”A store may use another store. The store of the requests asks the store of the login who is signed in right now and only shows that member's requests in its getter mine.
App.vue
<script setup>import { storeToRefs } from 'pinia'
import { useAuthStore } from './auth.js'import { useRequestsStore } from './requests.js'import { things } from './things.js'
const auth = useAuthStore()const requests = useRequestsStore()const { member } = storeToRefs(auth)const { mine } = storeToRefs(requests)</script>
<template> <p v-if="member"> Signed in as {{ member.name }} <button type="button" @click="auth.signOut()">Sign out</button> </p> <p v-else> <button type="button" @click="auth.signIn('M-117')">Sign in as M-117</button> <button type="button" @click="auth.signIn('M-204')">Sign in as M-204</button> </p> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" :disabled="!member" @click="requests.add(thing)">Request</button> </li> </ul> <h2>My requests</h2> <ul> <li v-for="request in mine" :key="request.id">{{ request.name }}</li> </ul></template>auth.js
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
// A stand-in for the members of the association until chapter 7.3 signs in against the course APIconst MEMBERS = [ { number: 'M-117', name: 'Martina Hostettler', team: true }, { number: 'M-204', name: 'member M-204', team: false },]
export const useAuthStore = defineStore('auth', () => { const member = ref(null) const isTeam = computed(() => member.value?.team === true)
function signIn(number) { member.value = MEMBERS.find((entry) => entry.number === number) ?? null return member.value !== null }
function signOut() { member.value = null }
return { member, isTeam, signIn, signOut }})main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')requests.js
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
import { useAuthStore } from './auth.js'
export const useRequestsStore = defineStore('requests', () => { // A store can use another store, Pinia hands out the same login everywhere const auth = useAuthStore() const all = ref([]) const mine = computed(() => all.value.filter((request) => request.member === auth.member?.number))
function add(thing) { if (auth.member === null || mine.value.some((request) => request.id === thing.id)) return all.value.push({ member: auth.member.number, id: thing.id, name: thing.name }) }
return { all, mine, add }})things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20, availableFrom: null }, { id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null }, { id: 6, name: 'Projector', deposit: 80, availableFrom: null }, { id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },]Pinia hands out the same store on every call of useAuthStore(), inside the other store too. When someone else signs in, mine is computed again.
A store in a navigation guard
Section titled “A store in a navigation guard”A guard runs outside of components. According to the Pinia documentation, it therefore calls the store inside its function and not at the top of the module. There, depending on the order of the imports, the call could run before Pinia is installed and fail.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
import { useAuthStore } from './auth.js'
const auth = useAuthStore()const route = useRoute()</script>
<template> <nav> <RouterLink to="/">Catalog</RouterLink> <RouterLink to="/team">Team</RouterLink> <button v-if="auth.member" type="button" @click="auth.signOut()">Sign out</button> </nav> <p>Address: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>auth.js
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
// A stand-in for the members of the association until chapter 7.3 signs in against the course APIconst MEMBERS = [ { number: 'M-117', name: 'Martina Hostettler', team: true }, { number: 'M-204', name: 'member M-204', team: false },]
export const useAuthStore = defineStore('auth', () => { const member = ref(null) const isTeam = computed(() => member.value?.team === true)
function signIn(number) { member.value = MEMBERS.find((entry) => entry.number === number) ?? null return member.value !== null }
function signOut() { member.value = null }
return { member, isTeam, signIn, signOut }})CatalogView.vue
<template> <h1>Catalog</h1> <p>60 things to borrow.</p></template>LoginView.vue
<script setup>import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './auth.js'
const auth = useAuthStore()const route = useRoute()const router = useRouter()
function signIn(number) { auth.signIn(number) const redirect = route.query.redirect router.replace(typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/')}</script>
<template> <h1>Sign in</h1> <p v-if="auth.member && !auth.isTeam">This area needs an account of the team.</p> <button type="button" @click="signIn('M-117')">Sign in as M-117</button> <button type="button" @click="signIn('M-204')">Sign in as M-204</button></template>main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
// Pinia comes first, the router starts navigating as soon as the app uses itcreateApp(App).use(createPinia()).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import { useAuthStore } from './auth.js'import CatalogView from './CatalogView.vue'import LoginView from './LoginView.vue'import TeamView from './TeamView.vue'
const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, { path: '/login', name: 'login', component: LoginView }, { path: '/team', component: TeamView, meta: { requiresTeam: true } }, ],})
router.beforeEach((to) => { // Inside the guard, when Pinia is already installed, not at the top of the module const auth = useAuthStore() if (to.meta.requiresTeam && !auth.isTeam) { return { name: 'login', query: { redirect: to.fullPath } } }})
export default routerTeamView.vue
<template> <h1>Team at the counter</h1> <p>For the team only.</p></template>As in chapter 6.4, a member without a team account ends up on the login page, and the information now comes from the store.
Stores in the developer tools
Section titled “Stores in the developer tools”According to the Vue documentation, Pinia connects to the Vue DevTools, with a timeline of the changes and a view into the stores that a component uses. The id of a store is its name in the developer tools. A create-vue project comes with the Vue DevTools as a Vite plugin, and its button appears at the bottom of the page while npm run dev is running.
Testing stores
Section titled “Testing stores”A store can be tested without a component. According to the Pinia documentation, setActivePinia(createPinia()) sets up a fresh Pinia before every test, and useAuthStore() then returns a fresh store.
let pinia
// A fresh Pinia for every test, the router guard uses it as wellbeforeEach(() => { pinia = createPinia() setActivePinia(pinia)})For components there's createTestingPinia from the package @pinia/testing. It replaces every action with a spy that only records its calls, and it takes the state a test should start from. With Vitest without global functions, it needs createSpy: vi.fn for that.
it('calls the action of the store on a click', async () => { // createTestingPinia replaces every action with a spy and takes the state to start from const testing = createTestingPinia({ createSpy: vi.fn, initialState: { auth: { member: { number: 'M-117', name: 'Martina Hostettler', team: true } } }, }) await router.push('/items/6') const wrapper = mount(App, { global: { plugins: [router, testing] } }) await flushPromises() await wrapper.find('button.request').trigger('click') expect(useRequestsStore(testing).add).toHaveBeenCalledWith(6) })In code
Section titled “In code”Work with the examples of this chapter. Read the code and write down what you expect before you change anything.
- In the first example, take
countout of thereturnof the store. What does the header show now? - In the example with
storeToRefs, click "Favorite" several times. Which number stays where it was, and why? - In the example with the two stores, sign in as M-117, request two things, then sign out and sign in as M-204. What does "My requests" show?
- In the example with the guard, move the line
const auth = useAuthStore()to the top of the module, beforecreateRouter. What happens on start?
Exercises
Section titled “Exercises”The exercises build the two stores from the assignment. You'll find the project in the course repo in the folder exercises/07-state/pinia/en. The login still lives there as a module in src/auth.js, as in part 6. Copy the folder, install the packages and start the tests.
npm installnpm testExercise Getting started
Turn src/auth.js into the store useAuthStore with the id auth in src/stores/auth.js. It has the state member, the getter isTeam and the actions signIn(number) and signOut(). signIn returns true when the number is known. The header, the login page and the guard use the store, and then you delete src/auth.js.
Solution
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
import { useRequestsStore } from './requests.js'
// A stand-in for the members of the association, until chapter 7.3 signs in against the course APIconst MEMBERS = [ { number: 'M-117', name: 'Martina Hostettler', team: true }, { number: 'M-204', name: 'member M-204', team: false },]
export const useAuthStore = defineStore('auth', () => { const member = ref(null) const isTeam = computed(() => member.value?.team === true)
function signIn(number) { member.value = MEMBERS.find((entry) => entry.number === number.trim().toUpperCase()) ?? null return member.value !== null }
function signOut() { member.value = null // Several members use the same device at the counter, nothing of the last one stays behind useRequestsStore().$reset() }
return { member, isTeam, signIn, signOut }})router.beforeEach((to) => { // Inside the guard, Pinia is installed by then const auth = useAuthStore() if (to.meta.requiresTeam && !auth.isTeam) { return { name: 'login', query: { redirect: to.fullPath } } }})With the line useRequestsStore().$reset(), the solution already contains the challenge. In the header, storeToRefs(auth) takes the store apart, and the action signOut is called through auth.signOut().
Exercise Going further
Create the store useRequestsStore with the id requests in src/stores/requests.js. It has the state list, the getter mine with the requests of the signed-in member and the action add(itemId). Without a login, add accepts nothing, and every member requests a thing at most once. The "Request" button on the page of a thing calls add, and the header shows the number of the own requests.
Solution
import { defineStore } from 'pinia'import { computed, ref } from 'vue'
import { useAuthStore } from './auth.js'
export const useRequestsStore = defineStore('requests', () => { const auth = useAuthStore() const list = ref([]) // The requests of whoever is signed in const mine = computed(() => list.value.filter((request) => request.member === auth.member?.number))
function add(itemId) { if (auth.member === null || mine.value.some((request) => request.itemId === itemId)) return list.value.push({ itemId, member: auth.member.number }) }
// A setup store has no $reset of its own, this one empties the list function $reset() { list.value = [] }
return { list, mine, add, $reset }})The store asks the store of the login inside its function. On the page of a thing, a computed value with requests.mine shows whether the thing has been requested already, and the button then reads "Requested".
Exercise Challenge
At the counter, several members sign in one after the other. Give the store of the requests an action $reset that empties the list, and call it on signing out.
Solution
// A setup store has no $reset of its own, this one empties the list function $reset() { list.value = [] } function signOut() { member.value = null // Several members use the same device at the counter, nothing of the last one stays behind useRequestsStore().$reset() }An option store gets $reset from Pinia, while a setup store needs an action of its own with that name, according to the documentation. The two stores import each other. That works because each one calls the other store only inside its functions.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
| The console reports that there is no active Pinia. | app.use(createPinia()) is missing in main.js, or a module calls the store while it loads. Call the store only inside a function. |
| A value from the store stays put in the component. | The store was taken apart directly. Use storeToRefs, or read through the store, such as auth.member. |
| A value doesn't show up in the developer tools. | The setup store doesn't return it. Return all of the state. |
store.$reset() reports an error. | Setup stores have no $reset of their own. Write an action with that name. |
An AI assistant writes a store with new Vuex.Store. | That's Vuex for older apps. New apps use Pinia with defineStore. |
Self-check
Section titled “Self-check”Self-check
- What becomes state, what becomes a getter and what becomes an action in a setup store?
- Why does a store that was taken apart directly lose the connection, and what helps?
- Where does a navigation guard call its store, and why?
- What does
createTestingPiniado with the actions of a store?
Answers
- Refs become state, computed values become getters and functions become actions.
- The store is a reactive object, and taking it apart creates copies of the values.
storeToRefscreates refs that stay connected to the store. - Inside its function. By then Pinia is installed for sure, while at the top of the module it might not be yet.
- It replaces them with spies that only record their calls, unless
stubActions: falseis set.
Assignment
The login and the requests have their stores, and at the counter nothing is left behind after signing out. For now, though, the requests only live in the browser's memory, and the login knows two members from the code. The team wants to see the requests, and the catalog should come from the association's server. That's what the next chapter is about.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.