Pinia 入門
はじめに
Vue3 から状態管理ライブラリとして Pinia が公式に推奨されるようになりました。
Pinia は Vuex の代替として位置づけられ、より柔軟でシンプルな状態管理を実現できます。
この記事では、Pinia の基本的な使い方を紹介します。
Pinia とは
Pinia は 2019年11月頃に、Composition API を使って Vuex の代替として開発が始まりました。
主な特徴は以下の通りです。
- TypeScript に対応
- Composition API に対応
- Vue Devtools に対応
- SSR に対応
Vuex と Pinia の違い
Vuex と Pinia は、ともに Vue.js アプリケーションの状態管理ライブラリですが、Store の設計思想に大きな違いがあります。
Store の構成
- Vuex: 1つの Store に複数の Module を登録する
- Pinia: Store ごとに Module を定義する
Vuex では、1つの Store に複数の Module を登録し、全体でツリー構造を形成します。
一方、Pinia では Store ごとに Module を定義し、フラットな構造になります。
ディレクトリ構造の比較
Vuex では、actions.js、mutations.js、modules ディレクトリを使って、アクション、ミューテーション、モジュールを分離します。
対照的に、Pinia では各 Store(モジュール)がそれぞれのファイルに定義されます。
# Vuex
└── store
├── index.js # モジュールを集めてストアをエクスポートする
├── actions.js # アクションのルートファイル
├── mutations.js # ミューテーションのルートファイル
└── modules
├── cart.js # cart モジュール
└── products.js # products モジュール
# Pinia
└── stores
├── index.ts # モジュールを集めてストアをエクスポートする
├── cart.ts # cart モジュール
└── products.ts # products モジュール
状態変更のワークフロー
- Vuex: コンポーネント → アクション → ミューテーション → 状態変更
- Pinia: コンポーネント → アクション → 状態変更
Vuex では、状態を変更するためにはミューテーションを経由する必要があります。これは、DevTools での状態変更の追跡と、一定のワークフローを促すためです。
一方、Pinia ではミューテーションの概念がなくなり、アクションから直接状態を変更できます。
これにより、コードがシンプルになり、開発者の自由度が上がります。
また、Pinia の DevTools 統合により、ミューテーションなしでも状態変更の追跡が可能になりました。
Mutation が不要とされた背景
背景を調査したところ、Issues で、Pinia の開発者が次のように述べています
After many years using Vuex, most mutations were completely unnecessary as they were merely doing a single operation
via an assignment (=) or collection methods like push.
つまり、Vuex を長年使用した経験から、ほとんどのミューテーションが単一の代入操作(=)やコレクションメソッド(pushなど)を行うだけで、完全に不要であることを発見したということです。
このため、Pinia ではミューテーションを廃止し、アクションから直接状態を変更することで、開発者の負担を軽減しました。
Pinia の基本的な使い方
ここから、Pinia の基本的な使い方を紹介します。
既に Vue3 アプリケーションがセットアップされていること・Pinia がインストールされていることを前提とします。
公式ドキュメント を参照して、Pinia をインストールしてください。
Vue3 + Pinia のセットアップ
Vue3 アプリケーションに Pinia をセットアップする手順は以下の通りです。
-
createPinia
関数を使って Pinia を初期化 -
createApp
関数にcreatePinia
を追加 -
mount
関数でアプリケーションをマウント
この手順を実装すると、Vue3 アプリケーションに Pinia が追加されます。
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
createApp(App)
.use(createPinia()) // Pinia を初期化して Vue3 アプリケーションに追加
.mount("#app");
Store の定義
Store を定義する手順は以下の通りです。
-
defineStore
関数を使って Store を定義 -
state
オブジェクトに状態を定義 -
actions
オブジェクトにアクションを定義 -
getters
オブジェクトにゲッターを定義
// stores/ProductStore.js
import { defineStore } from "pinia"
export const useProductStore = defineStore("ProductStore", {
state: () => {
return {
products: []
}
},
actions: {
async fill() {
this.products = (await import("../data/products")).default
}
}
})
// Action を呼び出す
// hoge.vue
const productStore = useProductStore()
productStore.fill()
$patch
で状態を更新
$patch
メソッドを使用すると、複数の状態変更を一度に行うことができ、リアクティブなシステムが効率的に更新を行います。
ただし、単一の代入操作や単純な状態変更の場合は、$patch を使わずに直接状態を変更することが推奨されます。
export const useUserStore = defineStore('user', {
state: () => ({
name: 'John Doe',
email: 'john@example.com',
age: 30
}),
actions: {
updateUser(userData) {
this.$patch({
name: userData.name,
email: userData.email,
age: userData.age
})
},
incrementAge() {
this.$patch((state) => {
state.age++
})
}
}
})
getters
の定義
getters
は、以下のような場合に便利です。
- 複数の状態を組み合わせて新しい値を算出する
- 算出した値をコンポーネントで使いたい
- 算出した値をキャッシュして再計算を防ぎたい
- 算出した値をストア内で共有したい
getters
を使うと、リアクティブな値を返すことができるので、Store の状態が変更されると自動的に再計算されます。
export const useUserStore = defineStore('user', {
state: () => ({
firstName: 'John',
lastName: 'Doe',
}),
getters: {
fullName: (state) => `${state.firstName} ${state.lastName}`,
},
})
$reset
メソッド
$reset
メソッドを使うと、Store の状態を初期値にリセットできます。
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter',
}),
actions: {
increment() {
this.count++
},
reset() {
this.$reset()
},
},
})
Store
から 別の Store
へのアクセス
Store
から別の Store
にアクセスする場合は、useOtherStore
を使って他の Store
をインポートします。
import { defineStore } from 'pinia'
import { useOtherStore } from './other-store'
export const useMyStore = defineStore('myStore', {
// ...
actions: {
async myAction() {
const otherStore = useOtherStore()
// otherStore を使った処理
},
},
})
$pinia
プロパティを使って、他の Store
にアクセスすることもできます。
import { defineStore } from 'pinia'
export const useMyStore = defineStore('myStore', {
// ...
actions: {
async myAction() {
const otherStore = this.$pinia.use('otherStore')
// otherStore を使った処理
},
},
})
Options API
での Store
の使い方
Pinia は、Options API と一緒に使うこともできます。
mapState と mapWritableState ヘルパー関数を使用することで、ストアの状態をコンポーネントにマッピングすることができます。
-
mapState
で Store の値をコンポーネントに読み取り専用でマッピング -
mapWritableState
で Store の値をコンポーネントに読み書き可能でマッピング可能で、v-model
を使って双方向バインディングできます。
<script>
import { mapState, mapWritableState } from 'pinia'
import { useAuthUserStore } from '@/stores/auth-user'
import CartWidget from '@/components/CartWidget.vue'
export default {
components: {
CartWidget
},
computed: {
...mapState(useAuthUserStore, {
username: (store) => store.username
}),
...mapWritableState(useAuthUserStore, [ 'email' ]),
greeting: function () {
return `Hello, ${this.username}!`
}
}
}
</script>
<template>
<input type="text" v-model="email"/>
<span class="mr-5">{{ greeting }}</span>
<CartWidget/>
</template>
Options API
での Store
のアクションの使い方
mapActions
ヘルパー関数を使用することで、ストアのアクションをコンポーネントにマッピングすることができます。
export const useAuthUserStore = defineStore("AuthUserStore", {
state: () => {
return {
username: "John Doe"
}
},
actions: {
visitTwitterProfile() {
window.open("https://twitter.com/" + this.username, "_blank")
}
}
})
<script>
export default {
methods: {
...mapActions(useAuthUserStore, {
toTwitter: "visitTwitterProfile"
})
}
}
</script>
<template>
<span class="mr-5" @click="toTwitter">{{ user }}</span>
</template>
HMR (Hot Module Replacement)
Pinia は、HMR(Hot Module Replacement)に対応しています。
- HMRは、開発中にソースコードを変更したときに、その変更を即座にブラウザに反映させるための機能
- 全体のページリロードを行わずに、変更したモジュールだけを更新できる
-
import.meta.hot
で モジュールバンドラ(この場合はVite.js)が HMR をサポートしているかどうかを確認
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCartStore, import.meta.hot))
}
// [vite] hot updated: /src/stores/CartStore.js
Subscribing to actions
-
store.$onAction()
で Store の Action 実行を監視
const cartStore = useCartStore()
cartStore.$onAction((
{
name, // name of the action
store, // store instance, same as `someStore`
args, // array of parameters passed to the action
after, // hook after the action returns or resolves
onError // hook if the action throws or rejects
}) => {
if (name === 'addItems') {
// this will trigger if the action succeeds and after it has fully run
after(() => {
console.log(`Added ${args[0]} ${args[1].name} to cart`)
})
// this will trigger if the action throws or returns a promise that rejects
onError((error) => {
console.warn(`Failed to add ${args[0].quantity} ${args[1].name} to cart`, error)
})
}
})
$subscribe
-
store.$subscribe()
で Store の変更を監視 - 状態が変更されるたびに、このコールバック関数が呼び出される
- state引数には、状態の新しい値が含まれます
<script setup>
const undo = () => {
if (history.length === 1) {
return
}
doingHistory.value = true
history.pop()
cartStore.$state = JSON.parse(history.at(-1))
doingHistory.value = false
}
cartStore.$subscribe((mutation, state) => {
if (!doingHistory.value) {
history.push(JSON.stringify(state))
}
})
</script>
<template>
<AppButton @click="undo">Undo</AppButton>
</template>
plugins
- Piniaストアの機能を拡張できる
- 共通して使用する機能をプラグインとして定義し、複数のストアで使用できる
-
Store
に対してplugin
が追加される
import { reactive, ref } from "vue"
export function piniaHistoryPlugin({ pinia, app, store, options }) {
if (!options.historyEnabled) return;
const history = reactive([])
const future = reactive([])
const doingHistory = ref(false)
history.push(JSON.stringify(store.$state))
store.$subscribe((mutation, state) => {
if (!doingHistory.value) {
history.push(JSON.stringify(state))
future.splice(0, future.length)
}
})
return {
history,
future,
undo: () => {
if (history.length === 1) {
return
}
doingHistory.value = true
future.push(history.pop())
store.$state = JSON.parse(history.at(-1))
doingHistory.value = false
},
redo: () => {
const latestState = future.pop()
if (!latestState) return
doingHistory.value = true
history.push(latestState)
store.$state = JSON.parse(latestState)
doingHistory.value = false
}
}
}
State
の永続化
-
VueUse の
useLocalStorage
を使用して、localStorage
にState
を永続化 - これにより、再読み込みした際もStateが保持される
export const useCartStore = defineStore("CartStore", {
historyEnabled: true,
state: () => {
return {
items: useLocalStorage("CartStore:items", [])
}
}
})
Discussion