当記事では、Vue.jsの状態管理ライブラリである「Pinia」を使用して状態管理を行う方法について解説する。筆者は以前「Vuex」を使用して状態管理を行なっていたが、Vue3のプロジェクトに関わるようになり「Pinia」を使用して状態管理を行うようになったため、知識の整理を兼ねてアウトプットすることにした。
状態管理とは?
状態管理とは、Vue コンポーネントインスタンスが持つリアクティブな状態(State)を管理することを指す。ここでいう状態(State)とは、アプリケーションにおいて、コンポーネントが持つデータを指す。例えば、カウンターアプリの場合、「5」という数値が状態となる。
なぜ状態管理が必要なのか?
Vueアプリケーションの状態管理の単位は複数あり、
- コンポーネント単体の状態管理
- ページ単位の状態管理(複数のコンポーネントで同一のデータを参照する)
- アプリケーション全体の状態管理(複数のコンポーネント、複数のルーティングで同一のデータを参照する)
などが挙げられる。特に、「2. ページ単位の状態管理」や「3. アプリケーション全体の状態管理」の場合では、複数のコンポーネントで同じデータを参照する必要が出てくる。そこで状態管理を用いることによって、以下のようにデータを管理、使用することができるようになる。
PropsやEmitではダメなのか?
複数のコンポーネントで同じデータを共有する場合に、Props(プロパティ)やEmit(カスタムイベント)を使用する方法もあるが、これらはコンポーネントが依存関係にある場合に使用できるため、アプリケーション全体の状態管理には不向きである。また、PropsとEmitのバケツリレーを回避するためにprovide/injectなどの機能もあるが、どのコンポーネントに対してデータをバインディングし、どこで受け取っているかの把握が難しいため、複数コンポーネントでの状態管理には不向きであると考える。
Piniaとは?
PiniaはVue.jsのための新たな状態管理ライブラリで、Vue 2とVue 3の両方で動作し、さらにComposition APIとOptions APIの両方で動作する。PiniaはVueアプリケーションにおいて、コンポーネントやページ間で共通の状態を管理することを容易にする。
Piniaの主要な特徴
- Devtoolsサポート: アクションやミューテーションを追跡するためのタイムラインを提供。
- ホットモジュールリプレースメント: ページのリロードなしにストアを変更可能。
- プラグイン: Piniaの機能をプラグインで拡張可能。
- TypeScriptサポート: TypeScriptのユーザーに堅牢な型推論サポートを提供。
- Server Side Rendering (SSR) サポート
Vuexとの比較
- Composition API スタイル: PiniaはComposition-APIスタイルのAPIを提供する。
- 型推論: TypeScriptと一緒に使用する際に、型推論のサポートが強化されている。
加えて、PiniaのAPIは、Vuexのmutationsは廃止されている。
Piniaの使い方(Vue3 Composition API)
前提: Viteで構築したVue3、TypeScript環境で動作確認を行なっている。
1. インストール
参照: https://pinia.vuejs.org/getting-started.html
yarn add pinia
# or with npm
npm install pinia
2. main.tsでpinia インスタンスを作成し、プラグインとしてアプリで使用できるようにする
参照: https://pinia.vuejs.org/getting-started.html
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import vuetify from './plugins/vuetify'
import { loadFonts } from './plugins/webfontloader'
import { createPinia } from 'pinia' // ←追加
const pinia = createPinia() // ←追加
loadFonts()
createApp(App)
.use(router)
.use(vuetify)
.use(pinia) // ←追加
.mount('#app')
3. ストアを定義(作成)する
参照: https://pinia.vuejs.org/core-concepts/
ここでは、数値をカウントするカウンター機能を作成する想定でストアを作成する。提供するデータ、機能は以下とする。
- state: カウントの数値
- getters: 1.のstateを2倍にした数値
- actions: カウントを増やす処理
src/storesにファイル(counterStore.ts)を作成する。
以下はどちらも同じ動作をする。個人的には、視覚的な見やすさからOption Storesを採用している。
Option Storesの定義
stateはストアのデータ、gettersはストアの計算プロパティ、actionsはメソッドを表現している。
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter-store', {
state: () => ({
count: 0
}),
getters: {
doubleCount: (state) => state.count * 2
},
actions: {
increment() {
this.count++
}
}
})
Setup Storesの定義
こちらはsetup関数と同じように記述し、refやcomputedなどのシステムを使用する記述法。
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useCounterStore = defineStore('counter-store', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, name, doubleCount, increment }
})
ストアはdefineStore()
を使用して定義され、第一引数に一意のID、第二引数にSetup関数またはOptionsオブジェクトを渡す。
4. コンポーネントでストアを使用する。
解説は、コメントを参照
<template>
<v-container>
<v-card>{{ count }}</v-card>
<v-card>{{ doubleCount }}</v-card>
<v-btn @click="increment"> + </v-btn>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { useCounterStore } from '@/stores/counterStore'
import { storeToRefs } from 'pinia'
export default defineComponent({
setup() {
/**
* ストアは、use...Store()がコンポーネント内で呼び出されるまで作成されない。
* setup()内で実行する必要がある
*
* ストアがインスタンス化されると、state、getters、
* およびactionsで定義された任意のプロパティに直接アクセスできるようになる。
*/
const store = useCounterStore()
/**
* ストアのリアクティビティを保持しながら、ストアからプロパティを抽出するためには、
* storeToRefs()を使用する必要がある。
*
* 注意: storeToRefsはストアのstate, gettersのみに適用するため、actionsは呼び出さない
*/
const { count, doubleCount } = storeToRefs(store)
/**
* actionsを呼び出す場合は、純粋にストアから分割代入する
*/
const { increment } = store
return {
count,
doubleCount,
increment
}
}
})
</script>
その他の機能
State
TypeScriptで型定義する
import { UserDocument } from '@/document/UserDocument'
export const useUsersStore = defineStore('users-store', {
state: () => ({
userList: [] as UserDocument[]
}),
})
ストアのState(状態)を初期化する
$reset
メソッドを呼び出して、ストアのStateを初期化できる。
コメント「追加」を参照。
const store = useCounterStore()
store.$reset()
複数のStateを一括で更新する
$patch
メソッドを呼び出して、複数の変更を同時に適用できる。
const store = useCounterStore()
store.$patch({
count: 5,
name: 'Peter'
})
Stateの変更を監視して処理を実行する
$subscribe()
メソッドを使用することで、ステートとその変更を監視することができる。
import { MutationType } from 'pinia'
で型情報をインポートできる。
const store = useCounterStore()
store.$subscribe((mutation, state) => {
/**
* 'direct' | 'patch object' | 'patch function'
*/
console.log('mutation.type', mutation.type)
/**
* defineStoreの第二引数の値
*/
console.log('mutation.storeId', mutation.storeId)
/**
* 以下のような出力となる
* keyでどの値が変更されたかを検出できる
* {
key: "count",
newValue: 1,
oldTarget: undefined,
oldValue: 0,
target: {coun: 1, name: 'James'},
type: "set",
}
*/
console.log('mutation.events', mutation.events)
/**
* ストアのStateの状態
*/
console.log('state', state)
})
コンポーネントが破棄された後も状態を保持したい場合は、$subscribe()
に{ detached: true }
を第二引数として渡して、ステートサブスクリプションを現在のコンポーネントから切り離す。
ただ個人的に、stateの値を個別に監視したい場合に、監視対象を明確にできない点が使いにくいと感じる。特定の値を監視したいのであれば、watchを使って個別に監視する方法が無難であると感じる。
Getters
getterに引数を渡す
computed関数に引数を渡すことはできないが、ストアのgetterでは引数を使って算出できる。
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter-store', {
state: () => ({
count: 0,
}),
getters: {
// gettersに引数を渡す
doubleCount: (state) => {
return (num: number) => state.count * num
}
},
actions: {
increment() {
this.count++
}
}
})
Actions
APIアクセスとその結果をstateに格納する
以下のコードは、下記の処理を行なっている。
- jsonplaceholderのAPIにアクセスし、ユーザー情報一覧を取得。
- 取得したデータをstateに格納。
- stateに格納されたユーザー一覧の配列を、idをkeyとするオブジェクトに変換
※ useAPIなどは別ファイルで定義しているがここでは解説しない。あくまでActionsの使い方を学習する。
import { defineStore } from 'pinia'
// UserDocumentは取得するユーザー情報の型定義
import { UserDocument } from '@/document/UserDocument'
// APIアクセスのComposition Function
import useAPI from '@/hooks/useAPI'
export const useUsersStore = defineStore('users-store', {
state: () => ({
// ユーザー情報の一覧を格納する
userList: [] as UserDocument[]
}),
getters: {
// 配列をオブジェクトに加工する。対象のユーザーを取得する際にfindなどのループ処理を行うよりも処理速度が上がる
usersMap: (state): Record<number, UserDocument> =>
state.userList.reduce(
(acc, userDoc) => {
if (userDoc.id) {
acc[userDoc.id] = userDoc
}
return acc
},
{} as Record<number, UserDocument>
)
},
actions: {
// ユーザー情報を取得する
async fetchUsers() {
const { data, get } = useAPI<UserDocument>()
const url = 'https://jsonplaceholder.typicode.com/users'
await get(url)
this.userList = data.value
},
// idを指定して対象のユーザーを取得する
getTargetUser(id: number) {
return this.usersMap[id]
}
}
})
コメント