📝

Pinia 入門

2024/04/03に公開

はじめに

Vue3 から状態管理ライブラリとして Pinia が公式に推奨されるようになりました。

Pinia は Vuex の代替として位置づけられ、より柔軟でシンプルな状態管理を実現できます。

この記事では、Pinia の基本的な使い方を紹介します。

Pinia とは

Pinia は 2019年11月頃に、Composition API を使って Vuex の代替として開発が始まりました。

主な特徴は以下の通りです。

  • TypeScript に対応
  • Composition API に対応
  • Vue Devtools に対応
  • SSR に対応

Vuex と Pinia の違い

Vuex と Pinia は、ともに Vue.js アプリケーションの状態管理ライブラリですが、Store の設計思想に大きな違いがあります。

Store の構成

  • Vuex: 1つの Store に複数の Module を登録する
  • Pinia: Store ごとに Module を定義する

Vuex では、1つの Store に複数の Module を登録し、全体でツリー構造を形成します。

一方、Pinia では Store ごとに Module を定義し、フラットな構造になります。

ディレクトリ構造の比較

Vuex では、actions.js、mutations.js、modules ディレクトリを使って、アクション、ミューテーション、モジュールを分離します。

対照的に、Pinia では各 Store(モジュール)がそれぞれのファイルに定義されます。

# Vuex
└── store
    ├── index.js          # モジュールを集めてストアをエクスポートする
    ├── actions.js        # アクションのルートファイル
    ├── mutations.js      # ミューテーションのルートファイル
    └── modules
        ├── cart.js       # cart モジュール
        └── products.js   # products モジュール
        
# Pinia
└── stores
    ├── index.ts          # モジュールを集めてストアをエクスポートする
    ├── cart.ts           # cart モジュール
    └── products.ts       # products モジュール

状態変更のワークフロー

  • Vuex: コンポーネント → アクション → ミューテーション → 状態変更
  • Pinia: コンポーネント → アクション → 状態変更

Vuex では、状態を変更するためにはミューテーションを経由する必要があります。これは、DevTools での状態変更の追跡と、一定のワークフローを促すためです。

一方、Pinia ではミューテーションの概念がなくなり、アクションから直接状態を変更できます。

これにより、コードがシンプルになり、開発者の自由度が上がります。

また、Pinia の DevTools 統合により、ミューテーションなしでも状態変更の追跡が可能になりました。

Mutation が不要とされた背景

背景を調査したところ、Issues で、Pinia の開発者が次のように述べています

After many years using Vuex, most mutations were completely unnecessary as they were merely doing a single operation
via an assignment (=) or collection methods like push.

つまり、Vuex を長年使用した経験から、ほとんどのミューテーションが単一の代入操作(=)やコレクションメソッド(pushなど)を行うだけで、完全に不要であることを発見したということです。

このため、Pinia ではミューテーションを廃止し、アクションから直接状態を変更することで、開発者の負担を軽減しました。

Pinia の基本的な使い方

ここから、Pinia の基本的な使い方を紹介します。

既に Vue3 アプリケーションがセットアップされていること・Pinia がインストールされていることを前提とします。

公式ドキュメント を参照して、Pinia をインストールしてください。

Vue3 + Pinia のセットアップ

Vue3 アプリケーションに Pinia をセットアップする手順は以下の通りです。

  • createPinia 関数を使って Pinia を初期化
  • createApp 関数に createPinia を追加
  • mount 関数でアプリケーションをマウント

この手順を実装すると、Vue3 アプリケーションに Pinia が追加されます。

// main.js

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

createApp(App)
    .use(createPinia()) // Pinia を初期化して Vue3 アプリケーションに追加
    .mount("#app");

Store の定義

Store を定義する手順は以下の通りです。

  • defineStore 関数を使って Store を定義
  • state オブジェクトに状態を定義
  • actions オブジェクトにアクションを定義
  • getters オブジェクトにゲッターを定義
// stores/ProductStore.js
import { defineStore } from "pinia"

export const useProductStore = defineStore("ProductStore", {
    state: () => {
        return {
            products: []
        }
    },

    actions: {
        async fill() {
            this.products = (await import("../data/products")).default
        }
    }
})

// Action を呼び出す
// hoge.vue
const productStore = useProductStore()
productStore.fill()

$patch で状態を更新

$patch メソッドを使用すると、複数の状態変更を一度に行うことができ、リアクティブなシステムが効率的に更新を行います。

ただし、単一の代入操作や単純な状態変更の場合は、$patch を使わずに直接状態を変更することが推奨されます。

export const useUserStore = defineStore('user', {
    state: () => ({
        name: 'John Doe',
        email: 'john@example.com',
        age: 30
    }),
    actions: {
        updateUser(userData) {
            this.$patch({
                name: userData.name,
                email: userData.email,
                age: userData.age
            })
        },
        incrementAge() {
            this.$patch((state) => {
                state.age++
            })
        }
    }
})

getters の定義

getters は、以下のような場合に便利です。

  • 複数の状態を組み合わせて新しい値を算出する
  • 算出した値をコンポーネントで使いたい
  • 算出した値をキャッシュして再計算を防ぎたい
  • 算出した値をストア内で共有したい

getters を使うと、リアクティブな値を返すことができるので、Store の状態が変更されると自動的に再計算されます。

export const useUserStore = defineStore('user', {
    state: () => ({
        firstName: 'John',
        lastName: 'Doe',
    }),
    getters: {
        fullName: (state) => `${state.firstName} ${state.lastName}`,
    },
})

$reset メソッド

$reset メソッドを使うと、Store の状態を初期値にリセットできます。

export const useCounterStore = defineStore('counter', {
    state: () => ({
        count: 0,
        name: 'Counter',
    }),
    actions: {
        increment() {
            this.count++
        },
        reset() {
            this.$reset()
        },
    },
})

Store から 別の Store へのアクセス

Store から別の Store にアクセスする場合は、useOtherStore を使って他の Store をインポートします。

import { defineStore } from 'pinia'
import { useOtherStore } from './other-store'

export const useMyStore = defineStore('myStore', {
    // ...
    actions: {
        async myAction() {
            const otherStore = useOtherStore()
            // otherStore を使った処理
        },
    },
})

$pinia プロパティを使って、他の Store にアクセスすることもできます。

import { defineStore } from 'pinia'

export const useMyStore = defineStore('myStore', {
    // ...
    actions: {
        async myAction() {
            const otherStore = this.$pinia.use('otherStore')
            // otherStore を使った処理
        },
    },
})

Options API での Store の使い方

Pinia は、Options API と一緒に使うこともできます。

mapState と mapWritableState ヘルパー関数を使用することで、ストアの状態をコンポーネントにマッピングすることができます。

  • mapState で Store の値をコンポーネントに読み取り専用でマッピング
  • mapWritableState で Store の値をコンポーネントに読み書き可能でマッピング可能で、v-model を使って双方向バインディングできます。

<script>
  import { mapState, mapWritableState } from 'pinia'
  import { useAuthUserStore } from '@/stores/auth-user'
  import CartWidget from '@/components/CartWidget.vue'

  export default {
    components: {
      CartWidget
    },
    computed: {
      ...mapState(useAuthUserStore, {
        username: (store) => store.username
      }),
      ...mapWritableState(useAuthUserStore, [ 'email' ]),
      greeting: function () {
        return `Hello, ${this.username}!`
      }
    }
  }
</script>

<template>
  <input type="text" v-model="email"/>
  <span class="mr-5">{{ greeting }}</span>
  <CartWidget/>
</template>

Options API での Store のアクションの使い方

mapActions ヘルパー関数を使用することで、ストアのアクションをコンポーネントにマッピングすることができます。

export const useAuthUserStore = defineStore("AuthUserStore", {
    state: () => {
        return {
            username: "John Doe"
        }
    },
    actions: {
        visitTwitterProfile() {
            window.open("https://twitter.com/" + this.username, "_blank")
        }
    }
})

<script>
  export default {
    methods: {
      ...mapActions(useAuthUserStore, {
        toTwitter: "visitTwitterProfile"
      })
    }
  }
</script>

<template>
  <span class="mr-5" @click="toTwitter">{{ user }}</span>
</template>

HMR (Hot Module Replacement)

https://pinia.vuejs.org/cookbook/hot-module-replacement.html

Pinia は、HMR(Hot Module Replacement)に対応しています。

  • HMRは、開発中にソースコードを変更したときに、その変更を即座にブラウザに反映させるための機能
  • 全体のページリロードを行わずに、変更したモジュールだけを更新できる
  • import.meta.hot で モジュールバンドラ(この場合はVite.js)が HMR をサポートしているかどうかを確認
if (import.meta.hot) {
    import.meta.hot.accept(acceptHMRUpdate(useCartStore, import.meta.hot))
}

// [vite] hot updated: /src/stores/CartStore.js

Subscribing to actions

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

  • store.$onAction() で Store の Action 実行を監視
const cartStore = useCartStore()

cartStore.$onAction((
    {
        name, // name of the action
        store, // store instance, same as `someStore`
        args, // array of parameters passed to the action
        after, // hook after the action returns or resolves
        onError // hook if the action throws or rejects
    }) => {
    if (name === 'addItems') {
        // this will trigger if the action succeeds and after it has fully run
        after(() => {
            console.log(`Added ${args[0]} ${args[1].name} to cart`)
        })

        // this will trigger if the action throws or returns a promise that rejects
        onError((error) => {
            console.warn(`Failed to add ${args[0].quantity} ${args[1].name} to cart`, error)
        })
    }
})

$subscribe

  • store.$subscribe() で Store の変更を監視
  • 状態が変更されるたびに、このコールバック関数が呼び出される
  • state引数には、状態の新しい値が含まれます

<script setup>
  const undo = () => {
    if (history.length === 1) {
      return
    }
    doingHistory.value = true
    history.pop()
    cartStore.$state = JSON.parse(history.at(-1))
    doingHistory.value = false
  }

  cartStore.$subscribe((mutation, state) => {
    if (!doingHistory.value) {
      history.push(JSON.stringify(state))
    }
  })
</script>
<template>
  <AppButton @click="undo">Undo</AppButton>
</template>

plugins

  • Piniaストアの機能を拡張できる
  • 共通して使用する機能をプラグインとして定義し、複数のストアで使用できる
  • Store に対して plugin が追加される
import { reactive, ref } from "vue"

export function piniaHistoryPlugin({ pinia, app, store, options }) {
    if (!options.historyEnabled) return;

    const history = reactive([])
    const future = reactive([])
    const doingHistory = ref(false)

    history.push(JSON.stringify(store.$state))

    store.$subscribe((mutation, state) => {
        if (!doingHistory.value) {
            history.push(JSON.stringify(state))
            future.splice(0, future.length)
        }
    })

    return {
        history,
        future,
        undo: () => {
            if (history.length === 1) {
                return
            }
            doingHistory.value = true
            future.push(history.pop())
            store.$state = JSON.parse(history.at(-1))
            doingHistory.value = false
        },
        redo: () => {
            const latestState = future.pop()

            if (!latestState) return

            doingHistory.value = true
            history.push(latestState)
            store.$state = JSON.parse(latestState)
            doingHistory.value = false
        }
    }
}

State の永続化

  • VueUseuseLocalStorage を使用して、localStorageState を永続化
  • これにより、再読み込みした際もStateが保持される

export const useCartStore = defineStore("CartStore", {
    historyEnabled: true,
    state: () => {
        return {
            items: useLocalStorage("CartStore:items", [])
        }
    }
})

参考文献

GitHubで編集を提案
Vue・Nuxt 情報が集まる広場 / Plaza for Vue・Nuxt.

Discussion