Skip to content

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:

  1. The login and the requests each live in a store.
  2. The header, the page of a thing and the guard read the login from the store.
  3. 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 storeToRefs without losing the connection,
  • use a store in another store and in a navigation guard,
  • test stores and components that use stores.

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')

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

App.vue
<script setup>
import AppHeader from './AppHeader.vue'
import ItemList from './ItemList.vue'
</script>
<template>
<AppHeader />
<ItemList />
</template>

AppHeader.vue

AppHeader.vue
<script setup>
import { useFavoritesStore } from './favorites.js'
// Every component that calls the function gets the same store
const 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

favorites.js
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
// A setup store: refs become state, computed values getters and functions actions
export 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

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

main.js
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')

things.js

things.js
// A few things of the catalog
export 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.

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

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 moment
const { count: copiedCount } = favorites
// storeToRefs makes a ref of every piece of state and every getter
const { count } = storeToRefs(favorites)
// Actions belong to the store and can be destructured directly
const { 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

favorites.js
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
// A setup store: refs become state, computed values getters and functions actions
export 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

main.js
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')

things.js

things.js
// A few things of the catalog
export 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.

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

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

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 API
const 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

main.js
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')

requests.js

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

things.js
// A few things of the catalog
export 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 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

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

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 API
const 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

CatalogView.vue
<template>
<h1>Catalog</h1>
<p>60 things to borrow.</p>
</template>

LoginView.vue

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

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 it
createApp(App).use(createPinia()).use(router).mount('#app')

router.js

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 router

TeamView.vue

TeamView.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.

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.

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.

level-2.test.js
let pinia
// A fresh Pinia for every test, the router guard uses it as well
beforeEach(() => {
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.

level-2.test.js
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)
})

Work with the examples of this chapter. Read the code and write down what you expect before you change anything.

  1. In the first example, take count out of the return of the store. What does the header show now?
  2. In the example with storeToRefs, click "Favorite" several times. Which number stays where it was, and why?
  3. 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?
  4. In the example with the guard, move the line const auth = useAuthStore() to the top of the module, before createRouter. What happens on start?

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.

Terminal window
npm install
npm test

Exercise 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
auth.js
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 API
const 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 }
})
index.js
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
requests.js
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
requests.js
// A setup store has no $reset of its own, this one empties the list
function $reset() {
list.value = []
}
auth.js
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.

MistakeWhat 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

  1. What becomes state, what becomes a getter and what becomes an action in a setup store?
  2. Why does a store that was taken apart directly lose the connection, and what helps?
  3. Where does a navigation guard call its store, and why?
  4. What does createTestingPinia do with the actions of a store?
Answers
  1. Refs become state, computed values become getters and functions become actions.
  2. The store is a reactive object, and taking it apart creates copies of the values. storeToRefs creates refs that stay connected to the store.
  3. Inside its function. By then Pinia is installed for sure, while at the top of the module it might not be yet.
  4. It replaces them with spies that only record their calls, unless stubActions: false is 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.