私たちが社内のBerry管理にPinia を導入してから、ほぼ1年が経ちました。この期間に、コードの構成、保守性、再利用性が明確に向上したことを実感しています。本記事では、すべてのロジックを.vue ファイルに詰め込んでいた頃から、Pinia、composables、view helpers を活用してロジックをモジュール化するまでの過程を共有します。
Berry管理 は、当社の業務プロセスを支援・効率化するために開発された社内向けのWebアプリケーションです。
このシステムは、複数の部門で活用されており、業務に必要な情報の共有や進捗の把握、データの一元管理を実現しています。利用部門ごとに、それぞれの業務に応じた機能が提供されており、社内の協力体制を強化する役割も果たしています。
Berry管理は、社内全体におけるデータの整合性と可視性を高め、関係者が共通の情報に基づいてスムーズに連携できる「信頼できる情報基盤」 として機能しています。
Piniaを導入する以前は、UI、ビジネスロジック、API連携、状態管理など、ほとんどすべてを.vue コンポーネントの中に記述していました。この方法は初期のうちは機能していましたが、プロジェクトが大きくなるにつれて、以下のような問題が生じました:
.vue ファイルは数百行に達し、可読性・保守性が低下。このままでは限界だと感じ、Pinia をはじめとするcomposables、view helpers の導入を決断しました。
Pinia は、複数のコンポーネント間で状態を共有したいときに使用します。アプリケーションの「単一の真実の情報源」として機能し、グローバルで必要とされる状態を一元管理できます。
私たちは、usePatientStore、useHelmetStore、useScanStore のように、ストアごとに役割を限定して設計しています。
Pinia がグローバルな状態管理に優れる一方で、composables は特定のビューに限定されつつも再利用可能なロジックに適しています。
1つのビューには通常2〜4個のcomposableを使用しており、それぞれが1つの責務を担っています:
use3dDataLayout():3Dのレイアウト・レンダリングを担当。usePatientTable():患者一覧のレンダリングロジックを管理。useHandle3dData():3Dデータの回転や移動などの操作を処理。Composables のおかげで、ビューのコードがスッキリし、可読性も向上しました。
すべての処理がリアクティブである必要はありません。純粋な計算や整形処理には、view helpers(ステートを持たないTypeScript関数)を使っています。
たとえば:
formatDateToLocalString(date: Date): stringcalculateDiscount(price: number, percent: number): numberbuildQueryStringFromFilters(filters: Record<string, any>): stringこれらは主にcomposableやPiniaのaction内で利用されます。dateHelpers.ts やproductHelpers.ts のように、ドメインごとに整理しています。
そのシンプルさが、コードの見通しを良くし、副作用のないロジックを保つ鍵となっています。
Pinia やcomposables は、ロジックの分離やコードの整理に非常に役立ちますが、正しく使わないと新たな問題を生みます。ここでは、私たちが実際に直面した2つの落とし穴と、その回避法を紹介します。
導入初期、私たちは1つのページに対して1つのPiniaストアを作成していました(例:usePatientDetailStore())。この中に、そのページのロジック・状態・アクションをすべて詰め込んでいたのです。
しかし時間が経つにつれ、以下のような問題が:
現在は、ストアを画面単位ではなく業務ドメイン単位に分けています:
usePatientStore() – 患者の基本情報、連絡先、メモなど。useScanStore() – 3Dスキャンのアップロード、メタデータ、プレビュー制御。useHelmetStore() – ヘルメットの調整、モデル設定、注文関連。この設計により:
フォルダ構成の例:
stores/ patient.ts scans.ts helmet.tsもう一つの重要な落とし穴は、状態管理の誤用です。特に、Pinia ストアを子コンポーネントの深い階層で直接インポートし、状態を変更するような使い方は危険です。
一見便利に思えますが、このアプローチは実際にはバグの原因になりやすく、追跡も困難 です。理由は以下の通りです:
// ChildComponent.vue 内import{ usePatientStore}from'@/stores/patient'const patientStore=usePatientStore()// 状態を直接変更(推奨されない)patientStore.currentTab='treatment-history'// ❌ NGこのような書き方は、一見シンプルに見えても、バグや不整合の温床になります。
状態を直接変更するのではなく、Pinia のアクションを通じて状態を更新するようにしましょう。これにより、すべての変更が明確で追跡しやすくなります。
// stores/patient.tsexportconst usePatientStore=defineStore('patient',()=>{const currentTab=ref('profile')functionsetCurrentTab(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}>()functiononTabClick(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 ChildComponentfrom'./ChildComponent.vue'const patientStore=usePatientStore()functionhandleTabChange(tab:string){ patientStore.setCurrentTab(tab)}</script><template><ChildComponent@tabChange="handleTabChange"/></template>この設計のメリット:
状態管理でバグを避け、アーキテクチャをきれいに保つためには:
| ❌ 悪い例 | ✅ 良い例 | 💡 さらに良い例 |
|---|---|---|
| 子でストアの状態を直接変更 | アクションを使って状態変更 | emitで親に任せて変更処理を集中 |
このパターンは Vue の原則「props down, events up(親から渡し、子は通知)」にも沿っており、アプリケーション全体の保守性と拡張性を高めます。
Piniaやcomposablesは非常に柔軟ですが、それだけに設計力が問われるツールでもあります。私たちが心がけているのは以下の3点です:
こうしたパターンにより、私たちのVueアプリケーションはよりスケーラブルで保守しやすくなりました。
