【Vue】Piniaの実践:ストアの分割と設計

JavaScript

こんにちは、かつコーチです。

前回はPiniaの基本的な使い方を解説しました。

今回は一歩進んで、実際のアプリ規模を想定した「ストアの分割と設計」を扱います。

小さいアプリでは1つのストアで十分ですが、機能が増えてくると必ず「ストアをどう分けるべきか」という設計判断が必要になります。

なぜストアの分割設計が必要になるのか

1つの巨大ストアが招く問題

Piniaは1つのストアに全部の状態を詰め込むこともできてしまいます。

しかし、ユーザー情報・カート・通知・検索条件など、性質の異なるデータを1つの useAppStore にまとめてしまうと、次のような問題が起きます。

  • 誰かがカート機能を直そうとしただけなのに、ユーザー情報のactionまで同じファイルに並んでいて見通しが悪い
  • テストを書くときに、無関係な状態まで一緒に初期化しないといけない
  • 複数人で開発していると、同じストアファイルへの変更が競合しやすい

私が実際につまずいたのは、案件の初期段階で「とりあえず楽だから」という理由で useAppStore を1つだけ作り、そこに認証情報・フォームの入力状態・通知メッセージを全部突っ込んでしまったケースです。

機能が増えるにつれてファイルが数百行に膨れ上がり、あるバグ修正で関係ない通知機能まで巻き込んで壊してしまったことがありました。

そこから「ドメイン(機能のまとまり)ごとにストアを分ける」という設計に切り替え、ようやく見通しが良くなった経験があります。

ドメインごとにストアを分割する

分割の基本方針

ストアを分けるときの基本方針は「一緒に変更されることが多いデータをまとめ、そうでないものは分ける」ことです。

stores/
  auth.ts       # ログインユーザー・認証トークン
  cart.ts       # カートの中身・合計金額
  notification.ts # 通知メッセージの一覧
// stores/auth.ts
import { defineStore } from "pinia"
import { ref, computed } from "vue"

export const useAuthStore = defineStore("auth", () => {
  const user = ref<{ id: number; name: string } | null>(null)

  const isLoggedIn = computed<boolean>(() => user.value !== null)

  function login(newUser: { id: number; name: string }): void {
    user.value = newUser
  }

  function logout(): void {
    user.value = null
  }

  return { user, isLoggedIn, login, logout }
})
// stores/cart.ts
import { defineStore } from "pinia"
import { ref, computed } from "vue"

interface CartItem {
  id: number
  price: number
  quantity: number
}

export const useCartStore = defineStore("cart", () => {
  const items = ref<CartItem[]>([])

  const totalPrice = computed<number>(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  function addItem(item: CartItem): void {
    items.value.push(item)
  }

  return { items, totalPrice, addItem }
})

それぞれのストアが「認証」「カート」という単一の責務に閉じているため、片方を修正してももう片方に影響しません。

ファイルも小さく保てるので、レビューもしやすくなります。

ストア同士を連携させる

別のストアを呼び出す

分割したストアが互いの情報を必要とする場面も出てきます。

たとえば「ログイン中のユーザーだけがカートに商品を追加できる」という制約をカートストアの中で持たせたいケースです。

❌ Before:呼び出し側で毎回チェックする

<script setup lang="ts">
import { useAuthStore } from "@/stores/auth"
import { useCartStore } from "@/stores/cart"

const authStore = useAuthStore()
const cartStore = useCartStore()

function handleAddToCart(item: { id: number; price: number; quantity: number }): void {
  // 呼び出すコンポーネントごとにこのチェックを書く必要がある
  if (!authStore.isLoggedIn) {
    alert("ログインしてください")
    return
  }
  cartStore.addItem(item)
}
</script>

このやり方だと、カートに商品を追加する箇所が増えるたびに、同じチェック処理をコピペすることになります。

✅ After:ストアの中で他のストアを呼び出す

// stores/cart.ts
import { defineStore } from "pinia"
import { ref, computed } from "vue"
import { useAuthStore } from "./auth"

interface CartItem {
  id: number
  price: number
  quantity: number
}

export const useCartStore = defineStore("cart", () => {
  const items = ref<CartItem[]>([])

  const totalPrice = computed<number>(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  function addItem(item: CartItem): boolean {
    const authStore = useAuthStore()
    if (!authStore.isLoggedIn) {
      return false
    }
    items.value.push(item)
    return true
  }

  return { items, totalPrice, addItem }
})

addItem の内部で useAuthStore() を呼び出すことで、「ログイン済みかどうか」のチェックがカートストアの中に閉じ込められます。

呼び出し側は addItem() を呼ぶだけでよくなり、チェック漏れの心配がなくなります。

Piniaの永続化を検討する

リロードで消えてしまう状態

Piniaのストアは、ページをリロードするとメモリ上のデータが初期化されてしまいます。

ログイン状態やカートの中身のように、リロード後も保持したいデータには工夫が必要です。

// stores/cart.ts(永続化の一例:localStorage連携)
import { defineStore } from "pinia"
import { ref, watch } from "vue"

interface CartItem {
  id: number
  price: number
  quantity: number
}

const STORAGE_KEY = "cart-items"

export const useCartStore = defineStore("cart", () => {
  const saved = localStorage.getItem(STORAGE_KEY)
  const items = ref<CartItem[]>(saved ? JSON.parse(saved) : [])

  watch(
    items,
    (newItems) => {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(newItems))
    },
    { deep: true }
  )

  function addItem(item: CartItem): void {
    items.value.push(item)
  }

  return { items, addItem }
})

watchdeep: true オプションで配列内部の変更まで検知し、変更のたびに localStorage へ保存しています。

同じような永続化処理が複数のストアで必要になる場合は、pinia-plugin-persistedstate のような専用プラグインの導入も検討する価値があります。

毎回同じ watch を書き続けるくらいなら、プラグインに任せたほうが保守性は高くなります。

ストア設計のチェックリスト

分割の判断軸

ストアを分けるかどうか迷ったときは、次の観点で考えると判断しやすくなります。

  • そのデータは、複数の異なる画面・機能から参照されるか(されないなら、コンポーネントローカルな状態や props で十分な場合もある)
  • そのデータの更新ロジックは、他のデータの更新ロジックと同時に変更されることが多いか
  • テストを書くとき、そのストア単体で初期化・検証が完結するか

これらに当てはめて「Yes/No」を整理するだけでも、なんとなく1つのストアに詰め込んでいた設計を見直すきっかけになります。

まとめ

この記事のポイント

  • 1つの巨大なストアは、見通しの悪化や変更の競合を招きやすい
  • ドメイン(機能のまとまり)ごとにストアファイルを分割するのが基本方針
  • ストアの中から別のストアを呼び出すことで、共通のチェックロジックを1箇所にまとめられる
  • リロードで消えたくないデータは、watch や専用プラグインで永続化を検討する

次に読むべき記事

ストアの設計まで理解できれば、Piniaを使った状態管理の土台は十分に整いました。

次回は、実際にaxiosを使ってAPIからデータを取得し、画面に表示する方法を解説していきます。

→ 次の記事:axiosでAPIからデータを取得して表示する

タイトルとURLをコピーしました