🍍

Pinia で宣言したストアを分割代入できるようにする

2024/03/31に公開

Vue 3 / Nuxt 3 において、それまでの Vuex に代わって公式に推奨されるグローバルストア管理ライブラリとなった Pinia。既に多くの Vue 3 プロジェクトで既に採用されていることでしょう。

https://pinia.vuejs.org/

しかし、Pinia を普通に使うと、「分割代入できない」という制限にやや不便さを感じるかもしれません。

この記事では、 defineStore() の機能を解説しつつ、簡単に分割代入するちょっとしたテクニックを紹介したいと思います。

TL;DR

この記事の最後で紹介するコードを貼っておきます。

store/useCounterStore.ts
const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const name = ref('Eduardo')
  const doubleCount = computed(() => count.value * 2)
  function increment() {
    count.value++
  }
  return { count, name, doubleCount, increment }
})

export default () => {
  const $store = useCounterStore()
  return { ...$store, ...storeToRefs($store) }
}

このコードを見て意味が理解できた方はこれ以降の説明を読む必要はありません。

defineStore はオブジェクトを返すが、分割代入は禁止されている

Pinia の defineStore() は、Vuex のようにオブジェクトで宣言する Options Store、 Composition API との互換性がある Setup Stores の両方に対応しています。

https://pinia.vuejs.org/core-concepts/

Composition API / Script Setup がメインストリームになっている現在では、コードの互換性の観点からも Setup Store が選択されることが多いと思います。

store/useCounterStore.ts
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const name = ref('Eduardo')
  const doubleCount = computed(() => count.value * 2)
  function increment() {
    count.value++
  }

  return { count, name, doubleCount, increment }
})

この時、return したそれぞれの変数は、Pinia によって Options Store における state や getters と同じものに変換されています。

ref()s become state properties
computed()s become getters
function()s become actions

このため、Composition API のフックを宣言する場合と違い、これらをコンポーネントなどで呼び出す場合には分割代入できないという制限があります。

その代わりストア内の個々の getter アクセスには .value も不要です。return しているのが ref オブジェクトであっても、Pinia を通すとストア全体が reactive() オブジェクトに変換される、と考えるのが近そうです。

<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// ❌ This won't work because it breaks reactivity
// it's the same as destructuring from `props`
const { name, doubleCount } = store
name // will always be "Eduardo"
doubleCount // will always be 0

setTimeout(() => {
  store.increment()
}, 1000)

// ✅ this one will be reactive
// 💡 but you could also just use `store.doubleCount` directly
const doubleValue = computed(() => store.doubleCount)
</script>

ちなみに、同様の挙動であった defineProps は、Vue 3.3 以降では分割代入してもリアクティブが切れないようになりましたが、Pinia はこの制限が続いています。

storeToRefs() を使うと分割代入できる

ストアから分割代入したい場合のために、Pinia では storeToRefs() という関数が用意されています。

https://pinia.vuejs.org/core-concepts/#Destructuring-from-a-Store

<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

const store = useCounterStore()
// `name` and `doubleCount` are reactive refs
// This will also extract refs for properties added by plugins
// but skip any action or non reactive (non ref/reactive) property
const { name, doubleCount } = storeToRefs(store)
// the increment action can just be destructured
const { increment } = store
</script>

store オブジェクトを渡すことで、ref/reactive オブジェクトが全て1つ1つ ref オブジェクトになります。

name.valuedoubleCount.value のようにアクセスするので、リアクティブな値かどうかの区別がしやすくなります。

一方、そもそもリアクティブでない action は ref オブジェクトに変換されないので、 storeToRefs ではスキップされます。しかし、元のストアから分割代入することができます。

storeToRefs をラップした関数を export する

ここまでの内容をまとめると、以下のことがわかります。

  • store をそのまま分割代入すると、リアクティブが切れてしまい変更を検知できなくなる
  • storeToRefs() を通すと、リアクティブな関数は ref オブジェクトに変換され、それ以外は skip され、分割代入可能になる
  • それ以外の action などは、storeToRefs() なしでも分割代入できる

では、最初から storeToRefs() でラップした状態をストアファイルから export すれば、コンポーネント側では何も気にせずに分割代入できるのではないか?

というのが最初に貼ったコードになります。

store/useCounterStore.ts
const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const name = ref('Eduardo')
  const doubleCount = computed(() => count.value * 2)
  function increment() {
    count.value++
  }
  return { count, name, doubleCount, increment }
})

export default () => {
  const $store = useCounterStore()
  return { ...$store, ...storeToRefs($store) }
}

storeToRefs を通した際に、リアクティブな値以外はスキップされてプロパティに残らないため、storeToRefsで返ってくるキーだけが $store を上書きし、それ以外は元のストアを分割代入したものを受け取ります。つまり、ストアの全てのキーが分割代入に対応した状態で return されるのです。

これで呼び出し元では

const { name, doubleCount, increment } = useCounterStore()

と書くことが可能になりました!

ちなみに、ここのコードでは default export を使っていますが、もちろんネーミングを変えて named export を使うこともできます。ストア名と関数名が一致していて、ストアファイルを Nuxt 3 の Auto Import 対象にしていれば useCounterStore という名前で呼び出し続けられますが、自動インポートを使わない場合は元のストアの名前を少し変えて、ラップ後の関数を export const useCounterStore とすることをオススメします。

どんなメリットがあるか

この方法のメリットは、なんといっても「分割代入は禁止されている」というルールを意識せずに済むことです。

「分割代入しても型としては正しそうに見えるのにリアクティブが切れている」というのは、コードから気づく方法がなく、Vue3やPiniaに慣れていない新規参画者が使ってしまっても型エラーになりません(Lintルールで検知できる方法はあるかもしれません)。

もちろん理想論としては開発者が Pinia ドキュメントを読んでおくべきだし、レビュワーが気付くべきではありますが、そうは言っても人間には限界があります。

その点、最初から「分割代入できます」「分割代入したら .value 経由でアクセスすることでリアクティブです」というのは非常にわかりやすいです。

現代のフロント開発において「TypeScriptの型に現れない制限」というのはとにかく認知負荷が高く、Vue Composition API で reactive() よりも型厳格な ref() が好まれている一因でもあるでしょうし、 defineProps() の分割代入サポートが歓迎された理由でもあります。

まとめ

Pinia ストアを分割代入する方法の紹介でした。個人的には1年ほど前から当たり前に使っているテクニックなのですが、あまり紹介している方が少なかったので書いてみました。

一見ハック気味に見えますが、 storeToRefs 処理を共通化しただけなので、 useCounterStore() を setup 関数でのみ呼んでいることには変わりなく、今後のアップデートで壊れる可能性も比較的低いと思います。

もし自分の見落としなどで上手く動作しないケースがあれば教えて頂けるとありがたいです!

お読みいただきありがとうございました。

Discussion