【Vue.js】Piniaを使った状態管理の始め方|Vuexとの違いと移行ガイド

【Vue.js】Piniaを使った状態管理の始め方|Vuexとの違いと移行ガイド Vue.js

Piniaは、Vueのコアチームが保守している状態管理ライブラリで、新しいアプリには、Piniaが推奨されています。これまで公式だったVuexは、現在は保守モードで、動作はしますが、新しい機能は追加されません。この記事では、Piniaの始め方と、つまずきやすい点、そしてVuexからの移行の手順を、公式の説明に沿って整理します。使い分けの全体像はコンポーネント間でデータをやりとりする方法をご覧ください。

スポンサーリンク

Piniaとは

状態管理ライブラリは、複数のコンポーネントから使う状態を、1か所にまとめて持つ仕組みです。Piniaには、次のような特長があります。公式は「なぜPiniaを使うのか」として、次の点を挙げています。

  • テスト用のユーティリティがある
  • プラグインで機能を拡張できる
  • TypeScriptに標準で対応している(JavaScriptでも補完が効く)
  • サーバーサイドレンダリング(SSR)に対応している
  • Vue DevToolsで、状態やアクションの履歴を確認できる
  • ホットモジュールリプレースメント(HMR)に対応している

導入

新しく始めるなら、Vueの公式ツールcreate-vue(npm create vue@latest)の対話で、Piniaを選ぶと、最初から組み込まれます。既存のプロジェクトには、npm install piniaで追加し、main.jsで登録します。

Shell:Piniaの導入
# 新しく始めるなら:npm create vue@latest の対話で「Pinia」を選ぶと、最初から組み込まれる
npm create vue@latest

# 既存のプロジェクトに追加する場合
npm install pinia
JavaScript:src/main.js
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

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

ストアの定義:2つの書き方

ストアは、defineStore()で定義します。第1引数には、アプリ全体で一意なストアのID(名前)を渡します。返ってくる関数は、use+ストア名+Store(useCartStoreなど)と名付けるのが慣例です。書き方は2つあります。

Option Store

state、getters、actionsのオブジェクトで書きます。Vueのdata、computed、methodsに近い感覚で書けます。

JavaScript:Option Store
// stores/counter.js(Option Store:state / getters / actions のオブジェクトで書く)
import { defineStore } from 'pinia'

// 第1引数は、アプリ全体で一意なストアの id。devtools とも結びつく
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  getters: {
    doubleCount: (state) => state.count * 2        // 算出プロパティに相当
  },
  actions: {
    increment() {
      this.count++                                 // mutations は無く、直接変更できる
    }
  }
})

Setup Store

関数で書きます。ref()がstate、computed()がgetters、関数がactionsになります。returnしたものだけが、ストアとして公開されます。Composition APIに慣れていれば、こちらが自然に書けます。

JavaScript:Setup Store
// stores/counter.js(Setup Store:関数で書く)
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)                              // ref() → state
  const doubleCount = computed(() => count.value * 2)   // computed() → getters
  function increment() {                            // 関数 → actions
    count.value++
  }

  return { count, doubleCount, increment }          // 公開するものを return する
})

どちらの書き方も使えます。Piniaが「Composition APIベース」というわけではなく、2つの書き方を選べる、というのが正確な理解です。refとcomputedの基本はComposition APIで状態を整理して管理する方法をご覧ください。

コンポーネントでストアを使う

<script setup>(またはsetup())の中で、useCounterStore()を呼ぶと、ストアが作られて使えます。

Vue:ストアを使う
<script setup>
import { useCounterStore } from './stores/counter'

// ストアは、この useXxxStore() を呼んだときに作られる
const counter = useCounterStore()
</script>

<template>
  <p>カウント: {{ counter.count }}(2倍: {{ counter.doubleCount }})</p>
  <button type="button" @click="counter.increment()">増やす</button>
</template>

分割代入するとリアクティブが切れる:storeToRefs

最も多い失敗が、ストアの分割代入です。公式も注意しているとおり、const { count } = counterと書くと、リアクティブな接続が切れて、画面が更新されなくなります。stateとgettersを分割代入するときは、storeToRefs()を使います。アクションは、そのまま分割代入して構いません。

Vue:storeToRefsで分割代入する
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from './stores/counter'

const counter = useCounterStore()

// NG:そのまま分割代入すると、リアクティブな接続が切れる
// const { count, doubleCount } = counter

// OK:state と getters は storeToRefs で ref のまとまりにしてから分割代入する
const { count, doubleCount } = storeToRefs(counter)

// actions は、そのまま分割代入してよい
const { increment } = counter
</script>

Vuexとの主な違い

公式が挙げる、Vuexとの主な違いは、次の4点です。

  • ミューテーションがない:Vuexでは冗長だったミューテーションが、なくなりました。stateは、アクションの中や、直接代入で変更できます。
  • TypeScriptの型推論に標準で対応:独自のラッパーを書かなくても、型が推論されます。
  • ストアはフラット:入れ子のモジュールがありません。ストアどうしは、互いにimportして組み合わせます。
  • 名前空間つきモジュールは不要:ストアの定義そのものが、名前空間になります。

VuexからPiniaへの移行

公式の移行ガイドに沿った手順です。

  1. モジュールをストアに変える:Vuexの各モジュールが、1つのストアになります。ストアのID(camelCaseが推奨)が、以前の名前空間の代わりです。
  2. stateを関数にする:オブジェクトで書いていたら、関数にします。
  3. gettersを整理する:stateのフィールドをそのまま返すだけのゲッターは、削除します。ほかのゲッターはthisで参照し、rootStateやrootGettersの代わりに、ほかのストアをimportして使います。
  4. actionsを書き換える:context引数を外します。必要なものは、すべてthisから使えます。
  5. mutationsをなくす:アクションに変えるか、ストアのプロパティへ直接代入します。
  6. 状態の初期化:組み込みの$reset()が使えます。ただし、Option Storeのみです。
JavaScript:VuexからPiniaへの書き換え
// Vuex のモジュール(Before)
export default {
  namespaced: true,
  state: { count: 0 },
  getters: { double: (state) => state.count * 2 },
  mutations: { INCREMENT(state) { state.count++ } },
  actions: { increment({ commit }) { commit('INCREMENT') } }
}

// Pinia のストア(After)
// ・モジュール → ストア。id が、名前空間の代わりになる
// ・mutations は無い → actions にまとめる(context 引数も不要)
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: { double: (state) => state.count * 2 },
  actions: {
    increment() {
      this.count++
    }
  }
})

公式は、移行の途中で、VuexとPiniaを一緒に使えると説明しています。Piniaのディレクトリがstoresという名前なのも、そのためです。モジュールを1つずつ、順番に変換できます。

次のステップ

よくある質問(FAQ)

Qストアを分割代入したら、画面が更新されなくなりました。
A分割代入すると、リアクティブな接続が切れます。stateとgettersは、storeToRefs(store)を通してから分割代入してください。アクションは、そのまま分割代入できます。
QOption StoreとSetup Storeは、どちらを使うべきですか?
Aどちらも使えます。Vueのdata・computed・methodsに近い感覚ならOption Store、Composition APIに慣れていればSetup Storeが書きやすいです。$reset()は、Option Storeでだけ使えます。
QVuexのミューテーションは、どうすればよいですか?
APiniaには、ミューテーションがありません。アクションに書き換えるか、ストアのプロパティへ直接代入してください。
QVuexとPiniaを、同時に使えますか?
A使えます。公式も、移行の途中で併用できると説明しています。モジュールを1つずつPiniaのストアに変換していけば、段階的に移行できます。

まとめ

Piniaは、defineStoreでストアを定義し、コンポーネントでuseXxxStore()を呼ぶだけで始められます。Option StoreとSetup Storeの2つの書き方から選べ、分割代入するときはstoreToRefsを使います。Vuexからは、モジュールをストアに、ミューテーションをアクションに変えていくことで、段階的に移行できます。