株式会社Berry
🕌

Piniaを導入して1年:Vueアプリケーションの状態管理をどうスケールさせたか

に公開

私たちが社内のBerry管理に Pinia を導入してから、ほぼ1年が経ちました。この期間に、コードの構成、保守性、再利用性が明確に向上したことを実感しています。本記事では、すべてのロジックを .vue ファイルに詰め込んでいた頃から、Piniacomposablesview helpers を活用してロジックをモジュール化するまでの過程を共有します。


Berry管理とは?

Berry管理 は、当社の業務プロセスを支援・効率化するために開発された社内向けのWebアプリケーションです。

このシステムは、複数の部門で活用されており、業務に必要な情報の共有や進捗の把握、データの一元管理を実現しています。利用部門ごとに、それぞれの業務に応じた機能が提供されており、社内の協力体制を強化する役割も果たしています。

Berry管理は、社内全体におけるデータの整合性と可視性を高め、関係者が共通の情報に基づいてスムーズに連携できる 「信頼できる情報基盤」 として機能しています。

Pinia導入前:ロジックが肥大化するVueファイル

Piniaを導入する以前は、UI、ビジネスロジック、API連携、状態管理など、ほとんどすべてを .vue コンポーネントの中に記述していました。この方法は初期のうちは機能していましたが、プロジェクトが大きくなるにつれて、以下のような問題が生じました:

  • 肥大化したコンポーネント:一部の .vue ファイルは数百行に達し、可読性・保守性が低下。
  • コードの重複:ロジックの再利用が難しく、コピー&ペーストやカスタムイベントによる対応に限界が。
  • 強い結合:ロジックがコンポーネントライフサイクルに密接に結びついており、テストや再利用が困難。

このままでは限界だと感じ、Pinia をはじめとする composablesview helpers の導入を決断しました。


Pinia:中央集約型のリアクティブな状態管理

Pinia は、複数のコンポーネント間で状態を共有したいときに使用します。アプリケーションの「単一の真実の情報源」として機能し、グローバルで必要とされる状態を一元管理できます。

主なユースケース

  • ユーザーセッションと認証情報:レイアウト、ナビゲーション、設定ページなどで共有。
  • グローバルUI状態:テーマ、言語、ローディング状態、モーダルの開閉など。
  • キャッシュ的データ:一度取得すれば複数のビューで再利用(例:ドロップダウンのオプションやカテゴリ情報)。

導入して感じたメリット

  • コンポーネントとの分離:状態やロジックをコンポーネントから切り離して、テストや再利用が簡単に。
  • DevTools対応:Vue DevTools で状態の追跡やデバッグが容易。
  • 型安全性:TypeScriptと組み合わせることで、安全に状態を読み書き可能。

私たちは、usePatientStoreuseHelmetStoreuseScanStore のように、ストアごとに役割を限定して設計しています。


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.tsproductHelpers.ts のように、ドメインごとに整理しています。

そのシンプルさが、コードの見通しを良くし、副作用のないロジックを保つ鍵となっています。


ピットフォール:私たちが苦労して学んだこと

Piniacomposables は、ロジックの分離やコードの整理に非常に役立ちますが、正しく使わないと新たな問題を生みます。ここでは、私たちが実際に直面した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アプリケーションはよりスケーラブルで保守しやすくなりました。

株式会社Berry
株式会社Berry

Discussion