99

Concurrent visitors over the last 30 minutes. (~)

Peak concurrent visitors -
Yesterday: 0 / Today: 0

Pinia Essentials

Pinia Essentials

This post is based on Pinia 4.0.3.

# Overview

Pinia is a library for state management in Vue applications.
It started as a new library that reflected ideas discussed for the next version of Vuex, and it has been Vue's official state management library since 2022.
With Vuex's mutations gone, changing data is much easier, and Pinia is especially friendly to the Composition API and TypeScript.
Vuex has not had a new version since 4.1.0 in October 2022, so if you are starting a new Vue project, using Pinia is strongly recommended.

# Installation and setup

Install Pinia as follows.

BASH
1
npm i pinia

Pinia 4 is an ESM-only package and requires Vue 3.5.11 or later and TypeScript 5.6 or later.
Also, @vue/devtools-api, which is used for devtools integration, has been split out as a peer dependency.
npm installs peer dependencies automatically, but with strict settings in pnpm or Yarn, you may need to install it yourself.

To use it in your project, register it as a Vue plugin as follows.

/src/main.ts
TS
1
2
3
4
5
6
7
8
9
import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const pinia = createPinia() createApp(App) .use(pinia) .mount('#app')

# Creating a store

You create a store to manage by calling the defineStore function.
This function takes as arguments a unique string that identifies the store instance (the store ID) and a value that defines the store.
The defineStore function returns a factory function that gives you the store instance.
This function is usually called a hook, and it is named with the use prefix and the Store suffix.
You can then call it from components or from outside them.

TS
1
2
3
import { defineStore } from 'pinia' export const useNameStore = defineStore('STORE_ID', STORE_DEFINITION)

There are two ways to define a store, depending on what you pass as the second argument.
If you pass the state, getters, and actions as an options object, it is an Option Store, and if you pass a function written with the Composition API syntax, it is a Setup Store.
The two only differ in syntax and produce the same kind of store, so just choose the one you are more comfortable with.

# Option stores

Let's look at a store that handles numbers (a count store) as an example.
For modularity, create a /src/stores/count.ts file and create the store with three options: state, getters, and actions.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { state: () => ({}), getters: {}, actions: {} })

# State

State is the reactive data defined in a store.
You define it in the state option of the store object, and you must write it as a factory function.
This prevents the state from being shared or reset unnecessarily even if the store instance is created multiple times.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { state: () => ({ count: 1, double: 2, negativeDouble: -2, isNegative: false, history: [] as number[] }) })

In the example, history is an array that stacks up the value of count each time it changes.
We added it to also look at how state that holds a value directly, like a number, behaves differently from state that holds a reference, like an array.
Having both kinds side by side makes it easy to compare why the $patch method, covered later, comes in an object form and a function form.

You can read each defined state directly from the store instance.
You get the store instance by calling the hook, as in useCountStore().

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts"> import { useCountStore } from '@/stores/count' const countStore = useCountStore() </script> <template> <h2>Count: {{ countStore.count }}</h2> <h2>Double: {{ countStore.double }}</h2> <h2>Negative Double: {{ countStore.negativeDouble }}</h2> <h2>Is Negative: {{ countStore.isNegative }}</h2> <h2>History: {{ countStore.history }}</h2> </template>

# Getters

Getters are functions that return values computed from the state, and as the name suggests, they are read-only.

In the following example, double and isNegative return values computed from the count state.
You can get the state object as the first parameter of a getter function.
However, negativeDouble now needs to be computed from the value of the double getter, and when you use an arrow function, there is no way to access other getters.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { state: () => ({ count: 1, history: [] as number[] }), getters: { double: state => state.count * 2, isNegative: state => state.count < 0 // negativeDouble: () => this.double * -1 // Does not work properly! } })

So if a getter needs to be computed from another getter's value, use a regular function instead of an arrow function, and you can access the state and other getters through the this keyword.
However, when you use the this keyword like this, you must specify the getter's return type.

If you understand the difference in the this keyword between regular functions and arrow functions, you can see why an arrow function cannot use the this keyword in the current structure.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { // ... getters: { double: state => state.count * 2, isNegative: state => state.count < 0, negativeDouble(): number { return this.double * -1 } } })

# Actions

Actions are functions you can run using the state and getters.
With the this keyword, they can access the state, getters, and other actions, and they can also change the state.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { state: () => ({ count: 1, history: [] as number[] }), getters: { double: state => state.count * 2, isNegative: state => state.count < 0, negativeDouble(): number { return this.double * -1 } }, actions: { increase(value = 1) { this.count += value this.history.push(this.count) }, decrease(value = 1) { this.count -= value this.history.push(this.count) } } })

You can access and call them directly on the store instance, as follows.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts"> import { useCountStore } from '@/stores/count' const countStore = useCountStore() </script> <template> <button @click="countStore.increase">Increase!</button> <button @click="countStore.decrease">Decrease!</button> <h2>Count: {{ countStore.count }}</h2> <h2>Double: {{ countStore.double }}</h2> <h2>Negative Double: {{ countStore.negativeDouble }}</h2> <h2>Is Negative: {{ countStore.isNegative }}</h2> <h2>History: {{ countStore.history }}</h2> </template>

You can also use asynchronous actions, as follows.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { // ... actions: { // ... async fetchCount() { const res = await fetch('https://api.heropy.dev/v0/count') const count = await res.json() this.count = count } } })

# Setup stores

If you pass a function as the second argument of defineStore, you can define the store with the Composition API syntax, just like the setup function of a Vue component.
ref corresponds to state, computed to getters, and regular functions to actions, and you return the things to use outside as an object.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { ref, computed } from 'vue' import { defineStore } from 'pinia' export const useCountStore = defineStore('count', () => { const count = ref(1) const history = ref<number[]>([]) const double = computed(() => count.value * 2) const negativeDouble = computed(() => double.value * -1) function increase(value = 1) { count.value += value history.value.push(count.value) } function decrease(value = 1) { count.value -= value history.value.push(count.value) } return { count, history, double, negativeDouble, increase, decrease } })

You use it in components the same way as an Option Store.
An advantage of Setup Stores is that you don't need the this keyword or an explicit return type when a getter references another getter.
In addition, you can use watch or call composables inside the store, which makes it more flexible than an Option Store.

# Destructuring and reactivity

The store instance is a reactive object, so destructuring it as follows loses reactivity.
count is fixed to its value at the moment of destructuring, so the screen doesn't update even when the state changes later.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
<script setup lang="ts"> import { useCountStore } from '@/stores/count' const countStore = useCountStore() const { count } = countStore // Loses reactivity! </script> <template> <h2>Count: {{ count }}</h2> </template>

In this case, the storeToRefs function converts each state and getter into a reactive ref so that you can destructure them.
Actions have nothing to do with reactivity, so destructure them directly from the store instance.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts"> import { storeToRefs } from 'pinia' import { useCountStore } from '@/stores/count' const countStore = useCountStore() const { count, double } = storeToRefs(countStore) const { increase, decrease } = countStore </script> <template> <button @click="increase">Increase!</button> <h2>Count: {{ count }}</h2> <h2>Double: {{ double }}</h2> </template>

# Instance members

With the various members that a store instance provides, you can use stores in even more ways.

The following examples are written with an Option Store.
Except for the $reset method, covered later, all instance members work the same way in a Setup Store.

# Checking the store ID ($id)

You can get the unique string that identifies the store instance, which you passed as the first argument of defineStore, with the $id property.

/src/App.vue
VUE
1
2
3
4
5
6
<script setup lang="ts"> import { useCountStore } from '@/stores/count' const countStore = useCountStore() console.log(countStore.$id) // 'count' </script>

# Checking the state object ($state)

You can access the state object of the store instance with the $state property.
It is an object that contains only the state defined in the state option, without getters and actions.
This object is also reactive, so changing a value as in countStore.$state.count = 100 works exactly the same as changing it through countStore.count.

/src/App.vue
VUE
1
2
3
4
5
6
<script setup lang="ts"> import { useCountStore } from '@/stores/count' const countStore = useCountStore() console.log(countStore.$state) // { count: 1, history: [] } </script>

# Changing state ($patch)

In components, you can both read and write the state.
If you change several pieces of state one by one, each change is recorded as a separate entry in the devtools, which makes the change history hard to follow.
In this case, the $patch method lets you handle multiple state changes as a single operation.
It is recorded as one entry in the devtools, and the callback of the $subscribe method, covered later, runs only once.
It also handles state changes explicitly, which improves readability.

In the following example, the handleClick function changes several pieces of state individually.

/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts"> import { useCountStore } from '@/stores/count' const countStore = useCountStore() function handleClick() { if (countStore.count > 100) { countStore.count = 100 countStore.history.push(100) } else { countStore.count += 1 countStore.history.push(countStore.count) } } </script> <template> <button @click="handleClick">Trigger!</button> <h2>Count: {{ countStore.count }}</h2> <h2>History: {{ countStore.history }}</h2> </template>

In this case, you can change multiple pieces of state at once with the $patch method, as follows.
This form is convenient for simple data changes, but it costs extra when you need to refer to existing values or change only part of a value (reference types).

TS
1
2
3
4
store.$patch({ state1: value, state2: value })
Changing with an object
/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts"> // ... function handleClick() { if (countStore.count > 100) { countStore.$patch({ count: 100, history: [...countStore.history, 100] }) } else { countStore.$patch({ count: countStore.count + 1, history: [...countStore.history, countStore.count + 1] }) } } </script>

To make up for this, the $patch method can also be used in function form, as follows.
The callback receives the state object as its parameter, so you can freely change each piece of state.

TS
1
2
3
4
store.$patch(state => { state.state1 = value state.state2 = value })
Changing with a function
/src/App.vue
VUE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts"> // ... function handleClick() { countStore.$patch(state => { if (countStore.count > 100) { state.count = 100 state.history.push(100) } else { state.count += 1 state.history.push(state.count) } }) } </script>

You can also use store members inside actions.
Access them with the this keyword.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { // ... actions: { increase(value = 1) { this.$patch(state => { state.count += value state.history.push(state.count) }) }, decrease(value = 1) { this.$patch(state => { state.count -= value state.history.push(state.count) }) } } })

# Subscribing to state ($subscribe)

With the $subscribe method, you can run a callback every time the store's state changes.
It is mainly used to save the state to local storage or sync it externally as it changes.

TS
1
const unsubscribe = store.$subscribe(callback, options?)

The callback receives the mutation info (mutation) and the changed state object (state) as arguments.
Most notably, with the type property of the mutation info, you can tell whether the state was changed directly ('direct') or with the $patch method ('patch object' | 'patch function').

TS
1
2
3
4
5
6
7
countStore.$subscribe((mutation, state) => { console.log(mutation, state) // Mutation info, changed state object console.log(mutation.type) // 'direct' | 'patch object' | 'patch function' console.log(mutation.storeId) // Store ID console.log(mutation.events.oldValue) // Value before the change console.log(mutation.events.newValue) // Value after the change })

The events property is information for the devtools and is provided only in development mode.
In production builds it is undefined, and its shape varies between a single object and an array depending on how the state was changed, so you should not use it in real logic.

As the second argument of the $subscribe method, you can pass Pinia's detached option and the Vue watch() options.
When you call the $subscribe method in a component, the subscription is removed automatically when that component unmounts, but if you set the detached option to true, the subscription is kept regardless of the component.
Note that the deep option defaults to true, so changes in nested state are detected even without setting it.
And the return value of the $subscribe method is a function that removes the subscription.

TS
1
2
3
4
5
6
7
8
9
10
11
12
const unsubscribe = countStore.$subscribe( (mutation, state) => { // ... }, { detached: true, flush: 'sync' // ... } ) unsubscribe() // Unsubscribe!

# Subscribing to actions ($onAction)

This subscribes to the store's action calls and runs a callback when an action runs.
It is useful for detecting or logging behavior before and after an action runs.

TS
1
2
3
4
5
6
7
8
const unsubscribe = countStore.$onAction(payload => { console.log(payload.name) // Action name console.log(payload.args) // Arguments of the action call console.log(payload.after) // Function to run after the action returns or resolves console.log(payload.onError) // Function to run when the action throws or rejects }) unsubscribe() // Unsubscribe!

As with the $subscribe method, when you call it in a component, the subscription is removed automatically when the component unmounts.
To keep it, pass true as the second argument.
Note that, unlike the $subscribe method, you pass a boolean value, not an options object.

TS
1
2
3
countStore.$onAction(payload => { // ... }, true) // Keep the subscription regardless of the component!

# Resetting state ($reset)

Calling the $reset method returns all changed state to its initial values.
It calls the factory function of the state option again to create a new state object and overwrites the current state with it.

/src/App.vue
VUE
1
2
3
<template> <button @click="countStore.$reset">Reset all state!</button> </template>

If you need to reset individual pieces of state, you have to write separate actions.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { defineStore } from 'pinia' export const useCountStore = defineStore('count', { state: () => ({ count: 1, history: [] as number[] }), // ... actions: { // ... resetCount() { this.count = 0 }, resetHistory() { this.history = [] } } })

The $reset method is available only in Option Stores.
Calling it in a Setup Store throws an error, so you need to write and return your own function that restores the state to its initial values.
For details, see the Resetting the state section of the official Pinia docs.

In an Option Store, the state option itself is a factory function that creates the initial state, so Pinia only has to call that function again to get the initial values.
A Setup Store, on the other hand, has no function that creates only the state.
Running the setup function again would recreate not only the state but also all the getters, actions, and watchers, so instead of guessing how to reset, Pinia throws an error.

So in a Setup Store, you create and return your own function that restores all state to its initial values.
If you name it $reset, the code that uses the store can call it as countStore.$reset(), just like with an Option Store.

/src/stores/count.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { ref, computed } from 'vue' import { defineStore } from 'pinia' export const useCountStore = defineStore('count', () => { const count = ref(1) const history = ref<number[]>([]) const double = computed(() => count.value * 2) function increase(value = 1) { count.value += value history.value.push(count.value) } function $reset() { count.value = 1 history.value = [] } return { count, history, double, increase, $reset } })

# Disposing a store ($dispose)

This stops the store's effect scope, removes all subscriptions, and then removes the store from the store registry.
An effect scope is a group of reactive effects created with Vue's effectScope() API.
Pinia creates one scope per store to hold its getters and watchers, so stopping the scope stops all of that store's reactive effects at once.
You can use it when you no longer need a store.

/src/App.vue
VUE
1
2
3
<template> <button @click="countStore.$dispose">Dispose store!</button> </template>

If you need the store again after disposing it, you have to call the hook again, as in useCountStore(), to get a new instance.
A disposed instance has been removed from the registry, so the getters of an Option Store throw errors; you should not reuse it as is.

The $dispose method does not delete the state.
So when you call the hook again, a new instance is created, but the state carries over the values from just before disposal instead of the initial values.
To clear the state as well, you have to delete it yourself, as in delete pinia.state.value[store.$id].

TS
1
2
3
4
5
6
7
const countStore = useCountStore() countStore.increase() countStore.$dispose() const newStore = useCountStore() console.log(newStore === countStore) // false, it is a new instance. console.log(newStore.count) // 2, not the initial value 1!