メインコンテンツまでスキップ

Pinia — ストアの定義と分割

12 章provide / inject は、コンポーネントの木の上から下へ値を配る仕組みでした。木の形に沿わない共有 — ヘッダーとカート画面が同じ買い物かごを見る、ログイン状態をどの画面からも読む — には向きません。

Pinia は木の外に状態を置きます。 defineStore() で作ったストアはアプリに 1 つだけ存在し、どのコンポーネントからも同じインスタンスが返ります。この章は Vue 公式が推奨する状態管理ライブラリとしての Pinia を扱います。

この章で学ぶこと

  • ストアを定義する 2 つの書き方 (Option Store / Setup Store) と、公式がどう使い分けを案内しているか
  • storeToRefs が必要になる場面と、action にだけ不要な理由
  • $patch の 2 つの形と、$state への代入がどう扱われるか
  • $subscribeいつ呼ばれるか。$patch と直接代入でタイミングが違う
  • $reset が Setup Store で使えない理由。開発ビルドと本番ビルドで挙動が違う

セットアップ

npm install pinia の後、アプリに差し込みます。

src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

createApp(App).use(createPinia()).mount('#app')

createPinia() が状態の入れ物を作り、app.use() でアプリに差し込みます。ストアの定義側は createPinia() を知りません。

2 つの書き方

Option Store

state / getters / actions の 3 つを持つオブジェクトを渡します。Options API に近い形です。

src/stores/cart.ts
import { defineStore } from 'pinia'

interface Item {
id: number
name: string
quantity: number
}

export const useCartStore = defineStore('cart', {
state: () => ({ items: [] as Item[] }),
getters: {
total: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
},
actions: {
add(item: Item) {
// 配列の操作は関数形の $patch でまとめる
this.$patch((state) => {
state.items.push(item)
})
},
async load() {
this.items = await (await fetch('/api/cart')).json()
},
},
})

action の中では this がストア自身を指します。Vuex の mutations に相当するものはなく、action から state を直接書き換えます。 async を付ければそのまま非同期 action になります。

Setup Store

関数を渡すと、その中身が <script setup> と同じ書き方になります。ref() が state、computed() が getters、関数が actions です。

src/stores/counter.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('田中')

const doubled = computed(() => count.value * 2)

function increment() {
count.value += 1
}

// state はすべて return する。返さないと $state に現れない
return { count, name, doubled, increment }
})

どちらを選ぶか

公式は Options API と Composition API の選択と同じ扱いをしていて、片方を推奨してはいません。「Options stores are easier to work with while Setup stores are more flexible and powerful」とし、慣れている方を選べと案内します。公式が Setup Store を "more flexible and powerful" と書くのは、<script setup> に書けることがそのまま書けるからです。たとえば定義の中に watch() を仕掛ける、$state に出さないローカル変数を持つ、書き込み可能な computed を返す — いずれも Option Store では書けません。逆に別のストアを呼ぶだけなら Option Store の action からもできます。 ただし useRouter() のように inject() を使う composable は action からは動きません (undefined が返り、開発ビルドでは警告が出ます) 。Setup Store の本体なら動きます。

代わりに Setup Store には注意が 1 つあります。state はすべて return しなければなりません。 公式は「Not returning all state properties or making them readonly will break SSR, devtools, and other plugins」と書いています。返し忘れた ref はストアの内部では生きています。そのストアを最初から動かしている間は、それを参照する computed や action が正しい値を返します。 見えなくなるのはその ref 自身です。

const useBad = defineStore('bad', () => {
const shown = ref(0)
const hidden = ref(0)
const sum = computed(() => shown.value + hidden.value)
function bump() {
shown.value += 1
hidden.value += 1
}
return { shown, sum, bump } // hidden を返していない
})

const store = useBad()
store.bump()

store.sum // 2 — hidden も 1 になっている
store.hidden // undefined — ストア経由では読めない
Object.keys(store.$state) // ['shown'] — hidden は入っていない

壊れるのは state を外から差し込むときです。SSR の hydration・devtools・プラグインはどれも $state を通して state を扱います。 サーバーで bump() を呼んでからクライアントへ state を渡すと、shown は 1 に復元されますが hidden は 0 から始まります。ブラウザ側の sum はサーバーでの 2 ではなく 1 になり、しかもエラーは出ません。 Nuxt のように SSR する構成では、これが「サーバーとクライアントで数字が違う」形で表に出ます。

コンポーネントから使う

use...Store() を呼ぶだけです。返るのはリアクティブなオブジェクトなので、テンプレートからは store.count の形でそのまま読めます。

問題は分割代入です。const { count } = store は値を取り出してしまうので、その後の変更に追随しません。 storeToRefs() を通すと ref になり、追随します。

src/components/CartSummary.vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '../stores/cart'

const store = useCartStore()

// state と getter は storeToRefs で ref にする
const { items, total } = storeToRefs(store)
// action はそのまま分割代入できる
const { add } = store
</script>

<template>
<p>{{ total }} 点</p>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
<button type="button" @click="add({ id: 1, name: '靴', quantity: 1 })">追加</button>
</template>

action を storeToRefs に通さないのは書き忘れではありません。storeToRefs が拾うのは ref と reactive なものだけです。公式は「It will create refs for every reactive property」と説明し、サンプルコードにこう注釈しています。

but skip any action or non reactive (non ref/reactive) property

落ちるのは action だけではありません。Setup Store が返した素の値 (const label = 'ラベル' のような ref でない定数) も含まれません。 action はストアに束縛された関数なので、取り出しても this を失いません。ref にする意味がないので対象外です。

ref の state は書き込めます。 <input v-model="count"> と書くと、入力がそのままストアの state に入ります。getter にも setter 付きの ref が返りますが、書き込みが届くかは元の定義次第です。Option Store の getters は read-only な computed になるので代入は効きません。Setup Store が computed({ get, set }) を返していれば届きます。

もう 1 つ届かない形があります。Setup Store が reactive() のオブジェクトを state として返しているとき、その ref へ別のオブジェクトを代入すると store 側だけが変わり、$state には届きません。 中の値を 1 つずつ書き換えれば届きます。

まとめて変える — $patch

複数の値を 1 回の変更として扱いたいときは $patch() を使います。2 つの形があります。

// オブジェクト形 — 差分を当てる
store.$patch({ count: 10, name: '佐藤' })

// 関数形 — patch オブジェクトで書きにくい変更をまとめる
store.$patch((state) => {
state.items.push(item)
state.hasChanged = true
})

関数形は配列の pushsplice のように、差分オブジェクトでは表しにくい変更のためのものです。どちらの形でも、中で行った変更は devtools の 1 エントリにまとまります。

$state への代入も $patch() になります。ただしマージの深さが違います。 オブジェクト形の $patch() は入れ子も再帰的にマージしますが、$state の setter は $patch($state => Object.assign($state, 渡した値)) なので 1 段しか見ません。

// state が { user: { name: '田中', age: 30 }, tag: 'a' } のとき
store.$patch({ user: { name: '佐藤' } })
store.user // { name: '佐藤', age: 30 } — age は残る

store.$state = { user: { name: '佐藤' } }
store.user // { name: '佐藤' } — age は消える
store.tag // 'a' — 1 段目は残る

$patch() が再帰するのは、両側が素のオブジェクトのときだけです。配列や Date のように素のオブジェクトでないもの、reactive() で包んだものは、入れ子にあっても丸ごと差し替わります。

入れ子を持つ state を部分的に更新したいなら $patch() を使ってください。$state の参照そのものは変わらないので、state の入れ物を差し替える手段にもなりません。

変更を受け取る — $subscribe

state が変わったときに呼ばれるコールバックを登録します。localStorage への保存が代表的な用途です。

src/components/CartPersist.vue
<script setup lang="ts">
import { useCartStore } from '../stores/cart'

const store = useCartStore()

// 既定ではこのコンポーネントのアンマウントで外れる
store.$subscribe((mutation, state) => {
localStorage.setItem('cart', JSON.stringify(state))
if (mutation.type === 'patch object') {
console.log(mutation.payload)
}
})
</script>

<template>
<p>{{ store.total }} 点を保存しています</p>
</template>

mutation.type は変更の入り口を表します。値は 3 つです。

type何をしたときpayload
'direct'store.count = 1 のように直接代入した無い
'patch object'$patch({ ... }) を呼んだ$patch() に渡したオブジェクト
'patch function'$patch(state => ...) / $state への代入 / Option Store の $reset()無い

payload が付くのは 'patch object' のときだけです。'patch function''direct' の mutation には payload というキー自体がありません。

'patch function' になる経路が 3 つあるのは、後ろの 2 つが内部で関数形の $patch() を呼んでいるからです。$subscribe から見ると、$reset() は「関数形の patch」と区別できません。

通知のタイミングが 2 種類ある

$patch はその場で通知します。直接代入は次のティックまで待ちます。 内部が Vue の watch() で、既定の flush'pre' だからです。$patch だけは patch の最後にコールバックを直接呼びます。

store.$subscribe((m) => log.push(m.type))

store.$patch({ count: 1 })
// log は ['patch object'] — もう入っている

store.count = 2
// log はまだ増えていない
await nextTick()
// ここで 'direct' が入る

$subscribe の第 2 引数は watch() のオプションがそのまま通ります。{ flush: 'sync' } を渡すと直接代入もその場で通知されます。

この差から、公式ガイドに書かれていない挙動が 1 つ出ます。既定の flush のまま $patch と同じティックで直接代入すると、その代入は通知されません。

store.$patch({ count: 1 })
store.count = 2
await nextTick()
// 値は 2 になっているが、通知は ['patch object'] だけ。'direct' は来ない

$patch は実行中に通知を止め、nextTick() で戻します。既定の flush: 'pre' の通知は同じティックの flush で走るので、戻る前に通り過ぎます。{ flush: 'sync' } を渡した subscription はこの取りこぼしをしません — 同期側のフラグは patch の通知より前に戻るので、両方が届きます。

$subscribe を state の変更ログとして使うなら、{ flush: 'sync' } を渡すか、$patch の直後に直接代入を混ぜないでください。既定のままでもティックを跨げば両方通知されます。

登録した subscription はいつ外れるか

既定では、$subscribe() を呼んだ時点で有効な effect スコープに紐づき、そのスコープが捨てられるときに外れます。 コンポーネントの setup がスコープを作るので、実際には「そのコンポーネントのアンマウントで外れる」と読めます。残したいときは { detached: true } を渡します。

紐づく相手は「コンポーネント」ではなくその時点のスコープです。Pinia のプラグインの中で $subscribe() を呼ぶと、ストア自身のスコープに紐づきます (プラグインはストアを作るスコープの中で走ります)。ストアを $dispose() すれば外れます。{ detached: true } を渡さずに残るのは、スコープがまったく無い場所 — モジュールのトップレベルなど — で呼んだものだけです。

// このコンポーネントが消えても保存を続ける
store.$subscribe(save, { detached: true })

同じ関数を 2 回渡すと、2 回目は捨てられます。 重複は登録されず、返ってくる解除関数は何もしない関数です。開発ビルドでは 3 行の診断が出ます。

[PINIA_R1007] The same callback was passed to "$subscribe()" of store "cart" more than once. Subscriptions are deduplicated, so the duplicate is ignored.
├▶ fix: Subscribe each callback only once. If you need to resubscribe, call the returned function to remove the previous subscription first, or create a new function.
╰▶ see: https://pinia.vuejs.org/core-concepts/state.html#Subscribing-to-the-state

捨てる動作そのものは本番ビルドでも同じで、消えるのは診断だけです。解除したいときは 1 回目の戻り値を持っておいてください。

mutation には devtools 用の events も入っていますが、本番ビルドでは undefined です。 開発ビルドでも中身の形は経路で変わります ($patch ではイベントの配列、直接代入では単一のイベント)。ログに残す用途で頼らないでください。

$reset は書き方で変わる

Option Store の $reset() は**state() を呼び直して結果を当て直します**。初期値を作る関数がそこにあるので、いつでも再実行できます。当て方は Object.assign なので、state() に無いキーは残ります (公式 doc は "replaces the current state" と書いていますが、実際は差し替えではありません) 。

Setup Store にはその関数がありません。公式は「you need to create your own $reset() method」と書いています。返すオブジェクトに $reset という名前の関数を入れれば、それがそのまま使われます。

src/stores/filters.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useFiltersStore = defineStore('filters', () => {
const keyword = ref('')
const onlyInStock = ref(false)

// Setup Store には元になる state() が無いので自分で書く
function $reset() {
keyword.value = ''
onlyInStock.value = false
}

return { keyword, onlyInStock, $reset }
})

書かないまま呼ぶとどうなるかはビルドで変わります。上の stores/counter.ts$reset を返していないので、これに当たります。

ビルド$reset() を呼んだとき
開発🍍: Store "counter" is built using the setup syntax and does not implement $reset(). を投げる
本番何もしない

実装が isOptionsStore ? ... : NODE_ENV !== 'production' ? () => { throw ... } : noop の形なので、本番の成果物では throw が空の関数に置き換わります。開発中に気づかず本番で「リセットボタンを押しても何も起きない」に化けるのがこの形の怖いところです。自分で $reset を書く方針にするなら、ストアを作るときに必ず入れてください。

なお Option Store の $reset() は内部で関数形の $patch() を呼びます。$subscribe から見ると 'patch function' として届くので、リセットだけを見分けられません。

ストアをまたぐ

ストアの中で別のストアを呼べます。use...Store() を setup の中で呼ぶだけです。

export const useReportStore = defineStore('report', () => {
const counter = useCounterStore()
const label = computed(() => `${counter.name}: ${counter.count}`)
return { label, bump: counter.increment }
})

Vuex のモジュールのように親子の入れ物を作る必要はありません。ストアは平らに並べ、必要な相手を直接 import します。 1 ストア 1 ファイルで src/stores/ に置くのが標準的な構成です。

defineStore() の第 1 引数の文字列 ('report') がストアの id です。devtools の表示名と pinia.state.value のキーになるので、ファイル名と揃えておくと追いやすくなります。

まとめ

  • Pinia はコンポーネントの木の外に状態を置きます。 createPinia() をアプリに差し込み、defineStore() で定義したストアをどこからでも use...Store() で取り出します
  • 書き方は Option Store と Setup Store の 2 つです。公式はどちらかを推奨しておらず、慣れている方を選べと案内します。Setup Store は <script setup> に書けることがそのまま書けます (定義中の watch()、ローカル変数、書き込み可能な computedinject() を使う composable など)
  • Setup Store は state をすべて return してください。 返し忘れた ref はストア内部では生きていますが、ストア経由では undefined$state にも現れません。SSR・devtools・プラグインはどれも $state を見るので、SSR では返さなかった値が復元されず、hydration 後のブラウザで computed が静かに違う値になります
  • const { count } = store は追随しません。 storeToRefs() を通せば ref になります。拾うのは ref と reactive なものだけで、action と素の値は含まれません。action はストアに束縛されているので store から直接分割代入しても動きます。得た ref への書き込みが届くかは元の定義次第です — ref の state は届き、Option Store の getter と reactive() state の丸ごと代入は届きません
  • $patch() にはオブジェクト形と関数形があります。関数形は配列操作のように差分で書きにくい変更のためのものです。オブジェクト形は入れ子も再帰的にマージしますが、再帰するのは両側が素のオブジェクトのときだけです (配列・Datereactive() は差し替え)
  • $state への代入も関数形の $patch() になりますが、中身は Object.assign なのでマージは 1 段だけです。入れ子は丸ごと差し替わります
  • $subscribe()mutation.type'direct' / 'patch object' / 'patch function' の 3 つです。payload が付くのは 'patch object' のときだけです。'patch function'$patch(関数) に加えて $state への代入と Option Store の $reset() でも出ます
  • $patch はその場で通知し、直接代入は次のティックで通知します。 そのため既定の flush では、$patch と同じティックの直接代入が通知されません{ flush: 'sync' } を渡した subscription は両方受け取ります
  • $subscribe は既定で呼び出し時点の effect スコープに紐づきます。 コンポーネントの中で呼べばアンマウントで、プラグインの中で呼べば $dispose() で外れます。残すには { detached: true } を渡します。同じ関数を 2 回渡すと 2 回目は捨てられ、その戻り値では解除できません。 mutation.events本番ビルドでは undefined です
  • $reset() は Option Store でしか既定で動きません。 Setup Store で書かずに呼ぶと、開発ビルドでは投げますが本番ビルドでは何も起きません。自分で $reset を返す形にしてください。Option Store の $reset()Object.assign なので state() に無いキーは残り、$subscribe からは 'patch function' として届きます
  • ストアの中から別のストアを呼べます。モジュールの入れ子は作らず、1 ストア 1 ファイルで平らに並べます
関連リファレンス

次に読む