当記事では、Vue.jsの状態管理ライブラリである「Pinia」を使用して状態管理を行う方法について解説する。筆者は以前「Vuex」を使用して状態管理を行なっていたが、Vue3のプロジェクトに関わるようになり「Pinia」を使用して状態管理を行うようになったため、知識の整理を兼ねてアウトプットすることにした。

状態管理とは?

状態管理とは、Vue コンポーネントインスタンスが持つリアクティブな状態(State)を管理することを指す。ここでいう状態(State)とは、アプリケーションにおいて、コンポーネントが持つデータを指す。例えば、カウンターアプリの場合、「5」という数値が状態となる。

なぜ状態管理が必要なのか?

Vueアプリケーションの状態管理の単位は複数あり、

  1. コンポーネント単体の状態管理
  2. ページ単位の状態管理(複数のコンポーネントで同一のデータを参照する)
  3. アプリケーション全体の状態管理(複数のコンポーネント、複数のルーティングで同一のデータを参照する)

などが挙げられる。特に、「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/

ここでは、数値をカウントするカウンター機能を作成する想定でストアを作成する。提供するデータ、機能は以下とする。

  1. state: カウントの数値
  2. getters: 1.のstateを2倍にした数値
  3. 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]
    }
  }
})

使用したAPI: https://jsonplaceholder.typicode.com/users

カテゴリー: Vue.js