vue-pinia について
Vue 3でPiniaを使うための開発スキルです。共有クライアント状態をsetup storeに整理し、コンポーネント内のUI状態やサーバーデータと区別する方法を扱います。
主な機能
- 01Composition APIのsetup store
- 02共有状態、getter、action
- 03storeToRefsによるリアクティブな参照
- 04SSRを考慮したstoreの利用
- 05永続化とテストの指針
活用シーン
- 01ルートやコンポーネント間での状態共有
- 02store actionによる業務フローのモデル化
- 03storeの動作を個別にテスト
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ mindrally/ skills --skill vue-piniaインストール後、Claude Code で/vue-piniaを入力して読み込まれたか確認します。
詳細
Vue + Pinia
Pinia は Vue 3 の標準的な状態管理ライブラリです。Composition API で作成するセットアップストアを使うと、アプリケーション間で共有する状態を、ローカルの ref/computed 状態と同じように扱えます。
Pinia ストアを追加するワークフロー
- Pinia が適切なツールか判断する — 状態がルートやコンポーネント間で共有されるものであり、サーバーキャッシュデータでも純粋なローカル UI 状態でもないことを確認します(「状態の所有者」を参照)。
- ストアファイルを作成する —
stores/の下にドメインごとのファイルを 1 つ追加します。例:stores/cart.ts。defineStore('cart', () => { ... })を使用します。 - 状態、ゲッター、アクションを定義する —
ref/reactiveで状態を宣言し、computedで値を導出し、すべての書き込みや副作用を関数(アクション)にまとめます。 - セットアップ関数からすべてを返す — 状態、ゲッター、アクションをすべて返し、Pinia devtools、SSR、プラグインから追跡できるようにします。
- コンポーネントで使用する —
<script setup>の冒頭でストアを呼び出し、リアクティブな状態はstoreToRefs()で分割代入し、アクションは直接分割代入します。 - SSR/ルーターガードでの使用に対応する — setup、ゲッター、アクションの内部でストアを呼び出すか、setup のコンテキスト外ではアクティブな Pinia インスタンスを明示的に渡します。
- 永続化とテストを追加する — 永続化するフィールドを許可リストで指定し、再ハイドレーションされたデータを検証し、
@pinia/testingでアクションを直接テストします。
状態の所有者
- コンポーネントだけで使用する状態は、
ref、reactive、またはcomputedを使ってコンポーネント内に保持します。 - ルート、レイアウト、または関係のないコンポーネントツリーにまたがって共有されるクライアント状態には Pinia を使用します。
- 共有可能なナビゲーション状態にはルートパラメーターとクエリー文字列を使用し、ストアに重複して保持しないでください。
- サーバー状態には Nuxt の
useFetch/useAsyncData、Vue 用 TanStack Query、または既存の API レイヤーを使用します。Pinia はクライアント状態用であり、データ取得キャッシュ用ではありません。 - サーバーキャッシュデータを Pinia にコピーしないでください。ただし、独自のライフサイクルを持つ、意図的に編集可能なドラフト、オフラインキャッシュ、またはワークフローのスナップショットである場合を除きます。
ストア構造
- Vue 3 Composition API のプロジェクトでは、旧式の Options API による
defineStore({ state, getters, actions })形式よりも、defineStore('name', () => { ... })を使ったセットアップストアを優先します。 stores/の下に、stores/cart.tsやstores/session.tsのようなドメインごとのストアファイルを 1 つずつ作成します。- 状態、computed ゲッター、アクションは、まとまりのある 1 つのドメインを表す場合は一緒に管理します。
- セットアップストアでは状態のプロパティをすべて返し、Pinia が devtools、SSR、プラグイン向けに追跡できるようにします。返されない
refはシステムのほかの部分から見えません。 - ゲッターは純粋で副作用のないものにします。書き込み、I/O、オーケストレーションはアクションにまとめます。
// stores/cart.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
interface CartLine {
id: string
name: string
quantity: number
unitPrice: number
}
export const useCartStore = defineStore('cart', () => {
const lines = ref<CartLine[]>([])
const itemCount = computed(() =>
lines.value.reduce((total, line) => total + line.quantity, 0),
)
const subtotal = computed(() =>
lines.value.reduce((total, line) => total + line.quantity * line.unitPrice, 0),
)
function addLine(line: CartLine) {
const existing = lines.value.find((item) => item.id === line.id)
if (existing) {
existing.quantity += line.quantity
return
}
lines.value.push(line)
}
function removeLine(id: string) {
lines.value = lines.value.filter((line) => line.id !== id)
}
function clearCart() {
lines.value = []
}
return {
lines,
itemCount,
subtotal,
addLine,
removeLine,
clearCart,
}
})
コンポーネントでの使用
- ストアは
<script setup>の冒頭、または setup 関数、ゲッター、アクションの内部で呼び出します。条件付きで呼び出したり、インポート時に読み込まれる通常の.js/.tsモジュール内で呼び出したりしないでください。 - コンポーネントでストアの状態やゲッターを分割代入する場合は、リアクティビティを維持できるよう
storeToRefs()を使用します。 - 必要に応じてアクションを直接分割代入します。アクションはストアにバインドされているため、
storeToRefs()は不要です。 - 大規模なビジネスワークフローをコンポーネントに記述しないでください。テストや再利用ができるよう、ストアのアクションまたはコンポーザブルに移します。
- 状態の導出にはウォッチャーより computed 値を優先します。値を再計算するだけの
watchは、通常computedに置き換えられます。
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
const { itemCount, subtotal } = storeToRefs(cart)
const { clearCart } = cart
</script>
<template>
<div>
<p>{{ itemCount }} items — ${{ subtotal.toFixed(2) }}</p>
<button type="button" :disabled="itemCount === 0" @click="clearCart">
Clear cart
</button>
</div>
</template>
TypeScript
- ストアの状態、アクションのペイロード、API レスポンスには明示的な型を付けます。
anyは避けます。unknownを使い、外部入力(API レスポンス、永続化されたストレージ)を絞り込んでから状態に反映します。- コンポーネント間または API 境界で共有されるオブジェクト状態には、インターフェースを使用します。
- ワークフローのステータスやエラー状態には、判別可能なユニオンを優先します。例:
{ status: 'idle' } | { status: 'loading' } | { status: 'error'; error: string }。 - ストア ID(
defineStoreの最初の引数)は、devtools や永続化キーに表示されるため、安定した説明的な値にします。
アクションと副作用
- アクションは同期または非同期にできます。各アクションは、ユーザーまたはドメインのワークフローを1つだけ扱うようにしてください。
- ストアの状態を変更する前に、アクションの入力を境界で検証してください。
- UIが非同期ワークフローに依存する場合は、明示的な
status、error、lastUpdatedAtフィールドで状態を表してください。 - 非同期アクションを再試行する前に古いエラーをリセットし、以前の失敗がUIに残らないようにしてください。
- サブスクリプション、インターバル、ソケット、ブラウザーのリスナーは、ストアがそのライフサイクルとクリーンアップを管理する場合(明示的な
connect()/disconnect()アクションのペアなど)を除き、ストアの外に置いてください。
SSR、Nuxt、ルーターガード
- SSRのコンテキストでは、Piniaがアクティブなアプリインスタンスを解決できるよう、setup、ゲッター、またはアクション内でストアを使用してください。
- ルーターガードなど、setupの外でストアを使用する場合、フレームワークが必要とするならアクティブなPiniaインスタンスを渡してください(Nuxtの自動インポートでは自動的に処理されます)。
- サーバーレンダリング中にブラウザー専用のストレージ(
localStorage、window)を読み込まないでください。import.meta.client(Nuxt)またはonMountedによるチェックで保護してください。 - Nuxtでは、Piniaインスタンスを手動で配線するのではなく、
@pinia/nuxt統合とSSR対応のコンポーザブルをデータ取得に使用してください。 - モジュールレベルのシングルトンではなく、リクエストごとにフレームワークが作成するPiniaインスタンスを使用し、リクエスト間でシングルトンの状態が漏れるのを防いでください。
// router guard example (outside setup)
router.beforeEach((to) => {
const auth = useAuthStore(pinia) // pass the active Pinia instance explicitly
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login' }
}
})
永続化
- 設定や未完了のローカル下書きなど、再読み込み後も保持する必要があるフィールドだけを永続化してください。
- シークレット、アクセストークン、リフレッシュトークン、生のPII、認可判断をブラウザーストレージに永続化しないでください。
- 永続化されたストアのスキーマにはフィールドの許可リストとバージョン管理されたマイグレーションを使用し、古い形式のデータで新しいバージョンのストアがクラッシュしないようにしてください。
- 永続化された状態は信頼できない入力として扱い、重要なワークフローで使用する前に検証してください。ユーザーは
localStorageを直接編集できます。 - 永続化された値に依存するUIをレンダリングする前に、ハイドレーションのタイミングを考慮してください。特にSSRでは、サーバーはブラウザーストレージにアクセスできません。
テストとツール
- ストアを必要とするコンポーネントテストには
@pinia/testing(createTestingPinia())を使用し、アクションはデフォルトでスタブ化してください。 - コンポーネントに依存せず、ドメインの振る舞いやエッジケースを検証するには、ストアのアクションを直接テストしてください。
- テストケース間で状態が共有されないよう、テストごとにPiniaをリセットしてください。
- プロジェクトでViteのHMRパターンを使用している場合は、ストアに
acceptHMRUpdate()を追加してHMRをサポートしてください。 - 明確な状態名を使い、単一のドメインに焦点を当てたストアにして、Vue Devtoolsでストアを調べやすくしてください。
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach } from 'vitest'
import { useCartStore } from '@/stores/cart'
describe('cart store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('merges quantities for duplicate line items', () => {
const cart = useCartStore()
cart.addLine({ id: '1', name: 'Widget', quantity: 1, unitPrice: 10 })
cart.addLine({ id: '1', name: 'Widget', quantity: 2, unitPrice: 10 })
expect(cart.itemCount).toBe(3)
})
})
アンチパターン
- すべてのリアクティブな値をPiniaに詰め込まないでください。ほとんどのコンポーネント状態はローカルに保つべきです。
storeToRefs()を使わずにストアから状態を直接分割代入しないでください。リアクティビティが気づかないうちに失われます。- ストアのアクションを通じてpropsやルートオブジェクトを変更しないでください。
- サーバー専用オブジェクト、リクエストインスタンス、DOMノード、タイマーをストアの状態に入れないでください。これらはシリアライズできず、SSRやDevtoolsで問題を引き起こします。
- setup関数内でストア間の循環参照を作らないでください。モジュールスコープで2つのストアが互いを読み取るのではなく、アクションや算出値を通じてストアを組み合わせてください。
コメント
Markdown 対応