はじめに
Vue.jsでアプリケーションが大きくなってくると、コンポーネント間でどうやって状態を共有するかが悩みの種になります。propsのバケツリレーやイベントの受け渡しが複雑に絡み合い、「このデータ、どこで更新されているんだっけ?」と追いかけ回した経験がある方も多いのではないでしょうか。
かつてVueの状態管理といえばVuexが定番でしたが、ミューテーションの記述量が多く、TypeScriptとの相性にも課題がありました。そこで登場したのがPiniaです。今ではVueコアチームによって公式に推奨される状態管理ライブラリとなり、多くの新規プロジェクトでVuexに代わる選択肢として採用されています。
とはいえ、読むより触った方が早いと思います。defineStoreで作ったストアがボタン操作でどう変化するか、先に挙動を見たい方はこちらからどうぞ。
Piniaとは
Piniaは、Vue.js向けの軽量な状態管理ライブラリです。Composition APIをベースに設計されており、型安全性・シンプルさ・DevToolsとの連携を兼ね備えています。開発者はEvan You氏率いるVueコアチームのメンバーでもあるEduardo San Martin Morote氏で、現在はVuexの実質的な後継としてVue公式ドキュメントでも案内されています。
主な特徴
- ミューテーション不要 - Vuexで必須だった
mutationsが廃止され、actionsから直接stateを更新できます。同期・非同期処理を区別なく書けるため、コードがシンプルになります。 - TypeScriptとの高い親和性 - 型推論が効くように設計されており、追加の型定義をほとんど書かずにstate・getters・actionsの型補完が得られます。
- モジュール構造がフラット - Vuexのようなネストしたモジュール構造を持たず、ストアごとに独立したファイルとして定義します。必要なストアだけをインポートするため、コード分割とも相性が良好です。
- DevTools対応 - Vue DevTools上でstateの変化やタイムトラベルデバッグが可能です。
- 軽量 - 依存が少なく、バンドルサイズへの影響を抑えた設計になっています。
- SSR対応 - Nuxtなどのサーバーサイドレンダリング環境でも問題なく利用できます。
インストール
Vue 3プロジェクトへのインストールは非常にシンプルです。
# npm
npm install pinia
# yarn
yarn add pinia
# pnpm
pnpm add pinia
Nuxtを利用している場合は、公式モジュールを使うとより簡単に導入できます。
npm install @pinia/nuxt
Vueアプリへの登録はmain.ts(またはmain.js)で行います。
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
Piniaのサンプルを動かす
まずは実際に手を動かして、PiniaのdefineStoreがどう働くかを見てみましょう。下のサンプルは、Piniaのカウンターストアを<button>で操作するフォームです。「+1」ボタンでstateのcountが増え、gettersで定義したdoubleCount(2倍の値)も連動して再計算されます。「リセット」ボタンを押すとactionsのresetが呼ばれ、stateが初期値に戻ります。
要点だけを抜き出すと、ストアの定義と呼び出しは次のようになります。
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment(step: number) {
this.count += step
},
reset() {
this.count = 0
},
},
})
実際に動かせるものが下です。ステップ数の入力欄を変えてから「+ステップ」ボタンを押すと、incrementアクションに渡す引数が変わり、カウントの増え方が変化します。
このサンプルではapp.use(createPinia())は行わず、defineStoreで作ったストアを単一コンポーネント内で直接呼び出しています。実際のアプリでは複数のコンポーネントから同じuseCounterStore()を呼び出すことで、同じstateが共有される点がポイントです。ステップ数を5にして「+ステップ」を連打すると、countとdoubleCountがどちらも同期して増えていく様子が確認できます。
基本的な使い方
PiniaではdefineStoreという関数を使ってストアを定義します。ここでは、シンプルなカウンターストアを例に見ていきましょう。
// stores/counter.ts
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
async incrementAsync() {
await new Promise((resolve) => setTimeout(resolve, 500))
this.count++
},
},
})
コンポーネント側では、useCounterStoreを呼び出すだけでストアにアクセスできます。
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>
<template>
<div>
<p>カウント: {{ counter.count }}</p>
<p>2倍の値: {{ counter.doubleCount }}</p>
<button @click="counter.increment">+1</button>
<button @click="counter.incrementAsync">+1(非同期)</button>
</div>
</template>
Composition APIスタイルで書きたい場合は、setup関数の形式でもストアを定義できます。refがstate、computedがgetters、通常の関数がactionsに対応します。
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
const name = ref('')
const isLoggedIn = computed(() => name.value !== '')
function login(userName: string) {
name.value = userName
}
function logout() {
name.value = ''
}
return { name, isLoggedIn, login, logout }
})
実践的なユースケース
実際のアプリケーションでは、API通信の結果をストアで管理するケースがよくあります。ここでは、記事一覧を取得するストアを例にします。
// stores/articles.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
interface Article {
id: number
title: string
}
export const useArticleStore = defineStore('articles', () => {
const articles = ref<Article[]>([])
const isLoading = ref(false)
const errorMessage = ref('')
async function fetchArticles() {
isLoading.value = true
errorMessage.value = ''
try {
const response = await fetch('/api/articles', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
throw new Error('記事の取得に失敗しました')
}
articles.value = await response.json()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '不明なエラーが発生しました'
} finally {
isLoading.value = false
}
}
return { articles, isLoading, errorMessage, fetchArticles }
})
複数のコンポーネントから同じストアを参照すれば、一度取得したデータをアプリ全体で使い回せます。たとえば一覧画面と詳細画面の両方で同じuseArticleStoreを呼び出しても、Piniaはシングルトンとしてインスタンスを管理してくれるため、状態が自動的に共有されます。
またテストの際には@pinia/testingパッケージを使うことで、ストアをモック化しやすくなっています。
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useCounterStore } from '@/stores/counter'
describe('counter store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('incrementでcountが増える', () => {
const counter = useCounterStore()
counter.increment()
expect(counter.count).toBe(1)
})
})
ストア単位でテストが完結するため、Vuexのようにモジュール全体をモックする手間もかかりません。
Piniaのフォーム入力とstateをstoreToRefsで同期する
フォームの入力値をPiniaのストアで一元管理したい場面もよくあります。ここで役立つのがstoreToRefsです。ストアを直接分割代入すると(const { name } = useUserStore()のように書くと)リアクティビティが失われてしまいますが、storeToRefsを使えばrefのリアクティブ性を保ったままstateやgettersだけを取り出せます(actionsはそのままストアから直接呼び出します)。
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// state・gettersはstoreToRefsで、actionsは直接分割代入する
const { name, isLoggedIn } = storeToRefs(userStore)
const { login, logout } = userStore
実際に動かせるサンプルが下です。入力欄に名前を打ち込むとv-model経由でストアのloginアクションが呼ばれ、isLoggedInゲッターの表示がリアルタイムに切り替わります。名前を空にすると再び未ログイン状態に戻る様子も確認できます。
storeToRefsで取り出したnameとisLoggedInは、テンプレート内でuserStore.nameと書かなくてもそのまま使えるのが利点です。入力欄を空にするとisLoggedInが自動的にfalseに切り替わり、Piniaのgettersがstateの変化を即座に反映していることが分かります。
Piniaの非同期アクションでローディング状態を管理する
先ほどの記事一覧ストアのように、非同期処理の途中経過(ローディング中かどうか)をstateとして持たせておくと、画面側でスピナーやボタンの無効化を出し分けやすくなります。ここでは擬似的な通信をsetTimeoutで再現し、actionsの中でisLoadingを切り替える様子を確認します。
export const useTaskStore = defineStore('task', {
state: () => ({ result: '', isLoading: false }),
actions: {
async fetchResult() {
this.isLoading = true
await new Promise((resolve) => setTimeout(resolve, 800))
this.result = '取得したデータ'
this.isLoading = false
},
},
})
実際に動かせるものが下です。「データ取得」ボタンを押すとfetchResultアクションが実行され、完了するまでボタンが無効化されて「読み込み中...」の表示に切り替わります。
ボタンを連打しても、isLoadingがtrueの間はdisabled属性が効くため多重実行されません。setTimeoutの待ち時間を800から3000などに書き換えると、ローディング中の表示がどれくらいの間続くかも体感できます。
まとめ
Piniaは、ミューテーションの廃止によるシンプルな記述、TypeScriptとの高い親和性、DevToolsによるデバッグのしやすさを兼ね備えた、Vue.js向けの状態管理ライブラリです。Vueコアチームによって公式に推奨される立場となったこともあり、これからVue 3でアプリケーションを構築するなら第一候補となる存在といえます。
まだVuexを使い続けているプロジェクトがあれば、小さなストアから少しずつPiniaへ移行してみるのも良いステップです。まずは今回紹介したカウンターストアのような簡単な例から、自分のプロジェクトに組み込んでみてください。