Piniaを導入して1年:Vueアプリケーションの状態管理をどうスケールさせたか
私たちが社内のBerry管理に Pinia を導入してから、ほぼ1年が経ちました。この期間に、コードの構成、保守性、再利用性が明確に向上したことを実感しています。本記事では、すべてのロジックを .vue
ファイルに詰め込んでいた頃から、Pinia、composables、view helpers を活用してロジックをモジュール化するまでの過程を共有します。
Berry管理とは?
Berry管理 は、当社の業務プロセスを支援・効率化するために開発された社内向けのWebアプリケーションです。
このシステムは、複数の部門で活用されており、業務に必要な情報の共有や進捗の把握、データの一元管理を実現しています。利用部門ごとに、それぞれの業務に応じた機能が提供されており、社内の協力体制を強化する役割も果たしています。
Berry管理は、社内全体におけるデータの整合性と可視性を高め、関係者が共通の情報に基づいてスムーズに連携できる 「信頼できる情報基盤」 として機能しています。
Pinia導入前:ロジックが肥大化するVueファイル
Piniaを導入する以前は、UI、ビジネスロジック、API連携、状態管理など、ほとんどすべてを .vue
コンポーネントの中に記述していました。この方法は初期のうちは機能していましたが、プロジェクトが大きくなるにつれて、以下のような問題が生じました:
-
肥大化したコンポーネント:一部の
.vue
ファイルは数百行に達し、可読性・保守性が低下。 - コードの重複:ロジックの再利用が難しく、コピー&ペーストやカスタムイベントによる対応に限界が。
- 強い結合:ロジックがコンポーネントライフサイクルに密接に結びついており、テストや再利用が困難。
このままでは限界だと感じ、Pinia をはじめとする composables、view helpers の導入を決断しました。
Pinia:中央集約型のリアクティブな状態管理
Pinia は、複数のコンポーネント間で状態を共有したいときに使用します。アプリケーションの「単一の真実の情報源」として機能し、グローバルで必要とされる状態を一元管理できます。
主なユースケース
- ユーザーセッションと認証情報:レイアウト、ナビゲーション、設定ページなどで共有。
- グローバルUI状態:テーマ、言語、ローディング状態、モーダルの開閉など。
- キャッシュ的データ:一度取得すれば複数のビューで再利用(例:ドロップダウンのオプションやカテゴリ情報)。
導入して感じたメリット
- コンポーネントとの分離:状態やロジックをコンポーネントから切り離して、テストや再利用が簡単に。
- DevTools対応:Vue DevTools で状態の追跡やデバッグが容易。
- 型安全性:TypeScriptと組み合わせることで、安全に状態を読み書き可能。
私たちは、usePatientStore
、useHelmetStore
、useScanStore
のように、ストアごとに役割を限定して設計しています。
Composables:ビュー固有のリアクティブロジック
Pinia がグローバルな状態管理に優れる一方で、composables は特定のビューに限定されつつも再利用可能なロジックに適しています。
1つのビューには通常2〜4個のcomposableを使用しており、それぞれが1つの責務を担っています:
-
use3dDataLayout()
:3Dのレイアウト・レンダリングを担当。 -
usePatientTable()
:患者一覧のレンダリングロジックを管理。 -
useHandle3dData()
:3Dデータの回転や移動などの操作を処理。
Composablesが便利な理由
- カプセル化:ロジックを1つにまとめて、コンポーネントをスリムに。
- テストのしやすさ:単体でモック化・テスト可能。
- 柔軟性:複数のビューで組み合わせ可能、重複も防げる。
Composables のおかげで、ビューのコードがスッキリし、可読性も向上しました。
View Helpers:シンプルでステートレスなユーティリティ
すべての処理がリアクティブである必要はありません。純粋な計算や整形処理には、view helpers(ステートを持たないTypeScript関数)を使っています。
たとえば:
formatDateToLocalString(date: Date): string
calculateDiscount(price: number, percent: number): number
buildQueryStringFromFilters(filters: Record<string, any>): string
これらは主にcomposableやPiniaのaction内で利用されます。dateHelpers.ts
や productHelpers.ts
のように、ドメインごとに整理しています。
そのシンプルさが、コードの見通しを良くし、副作用のないロジックを保つ鍵となっています。
ピットフォール:私たちが苦労して学んだこと
Pinia や composables は、ロジックの分離やコードの整理に非常に役立ちますが、正しく使わないと新たな問題を生みます。ここでは、私たちが実際に直面した2つの落とし穴と、その回避法を紹介します。
1. Viewごとのストア設計の落とし穴
導入初期、私たちは1つのページに対して1つのPiniaストアを作成していました(例:usePatientDetailStore()
)。この中に、そのページのロジック・状態・アクションをすべて詰め込んでいたのです。
しかし時間が経つにつれ、以下のような問題が:
- 肥大化:1ファイルに500行以上のロジック(UI制御・API・エラー処理など)が混在。
- 再利用困難:たとえば「ヘルメット調整」のロジックを他画面で再利用しづらい。
- テストしにくい:1つのストアに責務が集中しすぎる。
✅ ベストプラクティス:ドメインごとにストアを設計
現在は、ストアを画面単位ではなく業務ドメイン単位に分けています:
-
usePatientStore()
– 患者の基本情報、連絡先、メモなど。 -
useScanStore()
– 3Dスキャンのアップロード、メタデータ、プレビュー制御。 -
useHelmetStore()
– ヘルメットの調整、モデル設定、注文関連。
この設計により:
- メンテナンス・テストが容易に
- 新しい開発者のオンボーディングもスムーズに
- 機能拡張時のスケーラビリティ向上
フォルダ構成の例:
stores/
patient.ts
scans.ts
helmet.ts
2. 子コンポーネント内での状態の直接変更には注意
もう一つの重要な落とし穴は、状態管理の誤用です。特に、Pinia ストアを子コンポーネントの深い階層で直接インポートし、状態を変更するような使い方は危険です。
一見便利に思えますが、このアプローチは実際には バグの原因になりやすく、追跡も困難 です。理由は以下の通りです:
- 予期しない副作用:複数のコンポーネントが同じ状態を条件付きで変更すると、何が変更を引き起こしたのか分かりづらくなります。
- 強い依存関係:子コンポーネントがストアの構造に依存してしまい、再利用性が低下します。
- 制御不能な更新:ストアで定義されたロジックを通さずに状態を更新することで、一貫性が失われます。
❌ 悪い例:子コンポーネントで直接状態を変更する
// ChildComponent.vue 内
import { usePatientStore } from '@/stores/patient'
const patientStore = usePatientStore()
// 状態を直接変更(推奨されない)
patientStore.currentTab = 'treatment-history' // ❌ NG
このような書き方は、一見シンプルに見えても、バグや不整合の温床になります。
✅ より良い方法:ストアのアクションを使う
状態を直接変更するのではなく、Pinia のアクションを通じて状態を更新するようにしましょう。これにより、すべての変更が明確で追跡しやすくなります。
// stores/patient.ts
export const usePatientStore = defineStore('patient', () => {
const currentTab = ref('profile')
function setCurrentTab(tab: string) {
currentTab.value = tab
}
return { currentTab, setCurrentTab }
})
// 子コンポーネント内
const patientStore = usePatientStore()
patientStore.setCurrentTab('treatment-history') // ✅ クリーンで追跡可能
アクション経由にすることで、Vue DevToolsなどを使った状態の監視・デバッグもやりやすくなります。
emit
を使って親コンポーネントに処理を委ねる
✅ さらに良い方法:多くの場合、状態変更は親コンポーネントに任せる方がベターです。子コンポーネントはイベントを emit
し、親コンポーネントがそのイベントを受け取って状態を変更します。これにより、明確な単方向データフローが確立され、保守性も向上します。
<!-- ChildComponent.vue -->
<script setup lang="ts">
const emit = defineEmits<{
(e: 'tabChange', tab: string): void
}>()
function onTabClick(tab: string) {
emit('tabChange', tab)
}
</script>
<template>
<button @click="onTabClick('treatment-history')">履歴を見る</button>
</template>
<!-- ParentComponent.vue -->
<script setup lang="ts">
import { usePatientStore } from '@/stores/patient'
import ChildComponent from './ChildComponent.vue'
const patientStore = usePatientStore()
function handleTabChange(tab: string) {
patientStore.setCurrentTab(tab)
}
</script>
<template>
<ChildComponent @tabChange="handleTabChange" />
</template>
この設計のメリット:
- 子コンポーネントがステートレスになり、再利用性が高まる
- 親コンポーネントが状態変更を完全にコントロールできる
- 状態変更が明示的になり、トラブルシュートも簡単
状態管理でバグを避け、アーキテクチャをきれいに保つためには:
❌ 悪い例 | ✅ 良い例 | 💡 さらに良い例 |
---|---|---|
子でストアの状態を直接変更 | アクションを使って状態変更 |
emit で親に任せて変更処理を集中 |
このパターンは Vue の原則「props down, events up(親から渡し、子は通知)」にも沿っており、アプリケーション全体の保守性と拡張性を高めます。
まとめ
Piniaやcomposablesは非常に柔軟ですが、それだけに設計力が問われるツールでもあります。私たちが心がけているのは以下の3点です:
- ストアは画面単位ではなく業務ドメイン単位で設計
- 子コンポーネントからの状態変更を避け、常にアクションを使う
- ストアやcomposableが肥大化しないよう、定期的に見直しとリファクタ
こうしたパターンにより、私たちのVueアプリケーションはよりスケーラブルで保守しやすくなりました。
Discussion