こんにちは、かつコーチです。
前回まででComposition API編(provide/inject、ライフサイクルフック、コンポーザブル)を一通り解説してきました。
今回からは新しいテーマ、「状態管理」に入っていきます。
最初のテーマは、Vue公式の状態管理ライブラリPinia(ピニア)です。
Piniaとは?
アプリ全体で共有する状態を管理する仕組み
Piniaは、Vueアプリ全体で使うデータ(状態)を一箇所にまとめて管理するためのライブラリです。
Vue 2時代の定番だった「Vuex」の後継として、Vue公式に推奨されています。
ログインユーザーの情報、カートの中身、通知の一覧など、複数のコンポーネントから参照・更新したいデータを扱うのに向いています。
なぜprovide/injectだけでは足りないのか
前々回、provide/inject でコンポーネントをまたいだ値の共有ができることを解説しました。
「それならPiniaは要らないのでは?」と思うかもしれませんが、両者は得意分野が異なります。
provide/inject はあくまで「特定の祖先〜子孫の間」でのデータ受け渡しに向いた仕組みです。
一方Piniaは、アプリのどこからでも同じストアにアクセスでき、DevToolsでの状態の可視化やタイムトラベルデバッグにも対応しているなど、「アプリ全体の状態管理基盤」として設計されています。
私が実際につまずいたのは、最初は provide/inject だけでログイン状態を管理しようとして、無関係な複数のページ・複数のコンポーネント階層から同じユーザー情報にアクセスする必要が出てきたタイミングでした。
provide する場所とコンポーネント構造が一致しなくなり、「どこで provide されているのか」を毎回コードを遡って確認する羽目になったのです。
Piniaに切り替えたことで、ストアを1箇所定義するだけで、階層を気にせずどこからでも useXxxStore() を呼べるようになり、管理が一気に楽になりました。
Piniaのインストールとセットアップ
インストール
npm install pinia
アプリへの登録
// main.ts
import { createApp } from "vue"
import { createPinia } from "pinia"
import App from "./App.vue"
const app = createApp(App)
app.use(createPinia())
app.mount("#app")
createPinia() で作ったインスタンスを app.use() に渡すだけで、アプリ全体でストアが使えるようになります。
ストアを定義する
defineStoreの基本
Piniaでは defineStore を使ってストアを定義します。
// stores/counter.ts
import { defineStore } from "pinia"
import { ref, computed } from "vue"
export const useCounterStore = defineStore("counter", () => {
const count = ref<number>(0)
const doubleCount = computed<number>(() => count.value * 2)
function increment(): void {
count.value++
}
return { count, doubleCount, increment }
})
この書き方は「Setup Store」と呼ばれ、コンポーザブルとほぼ同じ感覚で書けます。
ref がstate(状態)、computed がgetter、通常の関数がactionに相当すると考えると理解しやすいです。
コンポーネントから使う
<script setup lang="ts">
import { useCounterStore } from "@/stores/counter"
const counterStore = useCounterStore()
</script>
<template>
<p>カウント:{{ counterStore.count }}</p>
<p>2倍:{{ counterStore.doubleCount }}</p>
<button @click="counterStore.increment">+1</button>
</template>
useCounterStore() を呼ぶだけで、どのコンポーネントからでも同じストアのインスタンスにアクセスできます。
複数のコンポーネントで useCounterStore() を呼んでも、内部的には同じデータを共有しているのがポイントです。
つまずきやすいポイント:storeの分割代入
リアクティビティが切れる典型パターン
コンポーザブルのときと同様、Piniaのストアでも分割代入には注意が必要です。
❌ Before:ストアのstateを分割代入する
<script setup lang="ts">
import { useCounterStore } from "@/stores/counter"
const { count, doubleCount } = useCounterStore()
// この時点でリアクティビティが切れてしまう
</script>
<template>
<p>カウント:{{ count }}</p>
</template>
ストアを分割代入で受け取ると、count が単なる数値としてコピーされてしまい、以降ストアの値が変わっても画面に反映されなくなります。
✅ After:storeToRefsを使う
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { useCounterStore } from "@/stores/counter"
const counterStore = useCounterStore()
const { count, doubleCount } = storeToRefs(counterStore)
</script>
<template>
<p>カウント:{{ count }}</p>
<p>2倍:{{ doubleCount }}</p>
</template>
Piniaが提供する storeToRefs() を使うと、state・getterを ref のまま安全に分割代入できます。
なお increment のようなaction(関数)は storeToRefs() を通さず、ストアから直接分割代入しても問題ありません。
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { useCounterStore } from "@/stores/counter"
const counterStore = useCounterStore()
const { count } = storeToRefs(counterStore)
const { increment } = counterStore // actionはそのまま分割代入してOK
</script>
「state・getterは storeToRefs、actionはそのまま」と覚えておくと迷いません。
Vuexとの違い(軽く)
書き方がシンプルになった
Vue 2時代のVuexでは mutation / action / getter を厳密に分けて書く必要があり、単純な値の更新にも決まった手順を踏む必要がありました。
Piniaでは ref を直接更新するだけで状態が変わるため、コード量が大きく減り、TypeScriptとの相性も改善されています。
これからVueで新しく状態管理を導入するなら、基本的にPiniaを選んで問題ありません。
まとめ
この記事のポイント
- PiniaはVue公式が推奨する状態管理ライブラリで、Vuexの後継にあたる
defineStoreの「Setup Store」記法は、コンポーザブルと同じ感覚で書ける- state・getterを取り出すときは
storeToRefs()を使い、リアクティビティを保つ - actionはそのまま分割代入してよい
次に読むべき記事
Piniaの基本が分かったところで、次回は実際のアプリ規模を想定した「ストアの分割と設計」を扱います。
機能ごとにストアをどう分けるべきか、上級者向けの設計パターンを解説します。
→ 次の記事:Piniaの実践:ストアの分割と設計