T-CREATOR

Pinia ストア分割テンプレ集:domain/ui/session の三層パターン

Pinia ストア分割テンプレ集:domain/ui/session の三層パターン

大規模な Vue.js アプリケーションで Pinia を使っていると、「このデータはどこに置くべき?」と迷うことがありますよね。

ストア設計を明確にしておかないと、後々メンテナンスが困難になり、状態管理が複雑化してしまいます。そこで今回は、Pinia のストアを domain(ドメイン)ui(UI)session(セッション) の三層に分割する設計パターンをテンプレート付きでご紹介します。

この記事を読むことで、チーム開発でも一貫性のある状態管理が実現でき、コードの見通しが格段に良くなります。

早見表:三層パターン適用ガイド

#レイヤー名役割主な状態例主なアクション例依存関係
1domainビジネスロジック・データ管理ユーザー情報、商品リスト、投稿データデータ取得、CRUD 操作、バリデーションなし(独立)
2uiUI 状態・表示制御モーダル表示状態、ローディング、選択中のタブモーダル開閉、ローディング切替、フィルター適用domain に依存可
3sessionセッション・認証情報ログイン状態、トークン、ユーザー設定ログイン、ログアウト、権限確認なし(独立)

適用判断チェックリスト

#状態の種類分類先判断基準
1ユーザーデータ、商品データdomainビジネスドメインに関連するデータか?
2モーダル表示、ローディング状態ui画面表示の制御に関わるか?
3ログイン状態、トークンsession認証・セッションに関わるか?
4フォーム入力値(一時的)uiページ遷移で破棄される一時データか?
5フォーム入力値(永続的)domainサーバーに保存される永続データか?

背景

Pinia における状態管理の課題

Vue 3 の登場とともに、Pinia は Vuex に代わる公式推奨の状態管理ライブラリとなりました。

Pinia はシンプルで直感的な API を提供し、TypeScript との相性も抜群です。しかし、プロジェクトが大きくなるにつれて「状態をどう整理するか」という設計上の課題が浮上してきます。

特に以下のような問題に直面することが多いでしょう。

  • ビジネスロジックと UI ロジックが混在し、どこに何があるか分からない
  • 認証情報やセッション情報が複数のストアに散在している
  • ストアの責務が曖昧で、どのストアに状態を追加すべきか判断できない

このような問題を解決するために、ストアを 役割ごとに明確に分割 することが重要です。

三層パターンの概要

三層パターンでは、Pinia のストアを以下の 3 つのレイヤーに分類します。

mermaidflowchart TB
  subgraph layer["ストア三層構造"]
    direction TB
    session["session<br/>セッション・認証"]
    ui["ui<br/>UI 状態・表示制御"]
    domain["domain<br/>ビジネスロジック・データ"]
  end

  ui -.->|参照可| domain
  ui -.->|参照可| session

  style session fill:#e1f5ff
  style ui fill:#fff4e1
  style domain fill:#e8f5e9

この図が示すように、各レイヤーは明確な役割を持ち、依存関係も整理されています。

図で理解できる要点

  • session と domain は独立しており、他のストアに依存しない
  • ui は domain や session を参照できるが、逆はできない
  • 依存の方向が一方向なので、循環参照を防げる

課題

ストア設計が曖昧な場合の問題

ストアの分割基準が明確でないと、以下のような問題が発生します。

1. 責務の混在

ビジネスロジックと UI ロジックが同じストアに混在すると、コードの見通しが悪くなります。

たとえば、ユーザー情報を管理するストアに「モーダルの開閉状態」が含まれていると、どこに何があるのか分かりにくくなってしまいます。

2. 再利用性の低下

UI 状態とビジネスロジックが密結合していると、別のページやコンポーネントで同じロジックを再利用することが困難になるでしょう。

3. テストの複雑化

ビジネスロジックと UI ロジックが混在していると、単体テストを書く際に不要な依存関係を抱え込むことになります。

結果として、テストケースが複雑になり、メンテナンスコストが上がってしまいます。

依存関係の混乱

ストア間の依存関係が整理されていないと、循環参照が発生しやすくなります。

mermaidflowchart LR
  userStore["userStore"] -->|参照| uiStore["uiStore"]
  uiStore -->|参照| sessionStore["sessionStore"]
  sessionStore -->|参照| userStore

  style userStore fill:#ffcccc
  style uiStore fill:#ffcccc
  style sessionStore fill:#ffcccc

このような循環参照は、デバッグを困難にし、予期しないバグを引き起こす原因となります。

図で理解できる要点

  • 循環参照が発生すると、どこから状態を追うべきか分からなくなる
  • 変更の影響範囲が予測できず、バグの温床になる

解決策

三層パターンによるストア分割

三層パターンでは、ストアを domainuisession の 3 つのレイヤーに明確に分割します。

各レイヤーの役割と責務を定義することで、どこに何を置くべきかが一目瞭然になります。

レイヤー 1:domain(ドメイン層)

役割:ビジネスロジックとドメインデータの管理

domain 層は、アプリケーションのコアとなるビジネスロジックとデータを管理します。

ユーザー情報、商品情報、投稿データなど、アプリケーションのドメインに関連する状態を扱いましょう。

特徴

  • 他のストアに依存しない(完全に独立)
  • API 通信やデータの CRUD 操作を担当
  • ビジネスルールやバリデーションロジックを含む

レイヤー 2:ui(UI 層)

役割:UI の表示状態と制御

ui 層は、画面の表示制御に関わる一時的な状態を管理します。

モーダルの開閉、ローディング状態、選択中のタブなど、ユーザーインターフェースに直接関わる状態を扱います。

特徴

  • domain や session を参照できる(一方向の依存)
  • ページ遷移やコンポーネントのアンマウント時にリセットされることが多い
  • ビジネスロジックは持たない

レイヤー 3:session(セッション層)

役割:認証情報とセッション管理

session 層は、ユーザーのログイン状態、認証トークン、ユーザー設定など、セッションに関わる情報を管理します。

特徴

  • 他のストアに依存しない(独立)
  • アプリケーション全体で共有される
  • 永続化(localStorage など)されることが多い

依存関係のルール

三層パターンでは、依存関係を以下のように定義します。

mermaidflowchart TB
  ui["ui レイヤー<br/>(UI 状態)"]
  domain["domain レイヤー<br/>(ビジネスロジック)"]
  session["session レイヤー<br/>(認証・セッション)"]

  ui -.->|参照可| domain
  ui -.->|参照可| session

  style ui fill:#fff4e1
  style domain fill:#e8f5e9
  style session fill:#e1f5ff

ルール

  • domain と session は独立:他のストアを参照しない
  • ui は domain と session を参照可:ただし、逆方向の参照は禁止
  • 循環参照の禁止:すべてのレイヤー間で循環参照を避ける

図で理解できる要点

  • 依存の方向が一方向なので、コードの追跡が容易
  • domain と session が独立しているため、単体テストが書きやすい
  • ui が上位層として他を参照する構造

具体例

プロジェクト構成

まずは、Pinia ストアのディレクトリ構成を確認しましょう。

csssrc/
├── stores/
│   ├── domain/
│   │   ├── useUserStore.ts
│   │   ├── useProductStore.ts
│   │   └── usePostStore.ts
│   ├── ui/
│   │   ├── useModalStore.ts
│   │   ├── useLoadingStore.ts
│   │   └── useFilterStore.ts
│   └── session/
│       ├── useAuthStore.ts
│       └── usePreferenceStore.ts
└── main.ts

この構成により、どのストアがどのレイヤーに属しているかが一目で分かります。

domain 層の実装例:useUserStore

domain 層では、ビジネスロジックとデータの管理を行います。

ここでは、ユーザー情報を管理する useUserStore を実装してみましょう。

インポートと型定義

typescript// src/stores/domain/useUserStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

Pinia の defineStore と Vue の refcomputed をインポートします。

ユーザー型の定義

typescript// ユーザー情報の型定義
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

TypeScript の型定義により、型安全性を確保します。

ストアの定義:状態管理

typescriptexport const useUserStore = defineStore('user', () => {
  // 状態:ユーザーリスト
  const users = ref<User[]>([])

  // 状態:ローディング状態
  const isLoading = ref(false)

  // 状態:エラー情報
  const error = ref<string | null>(null)

ref を使って、リアクティブな状態を定義します。

算出プロパティ(Getters)

typescript// 算出:管理者ユーザーのみを取得
const adminUsers = computed(() => {
  return users.value.filter(
    (user) => user.role === 'admin'
  );
});

// 算出:ユーザー総数
const userCount = computed(() => users.value.length);

computed を使って、状態から派生する値を定義します。

アクション:データ取得

typescript// アクション:ユーザー一覧を取得
const fetchUsers = async () => {
  isLoading.value = true;
  error.value = null;

  try {
    const response = await fetch('/api/users');
    if (!response.ok)
      throw new Error('Failed to fetch users');

    const data = await response.json();
    users.value = data;
  } catch (e) {
    error.value =
      e instanceof Error ? e.message : 'Unknown error';
  } finally {
    isLoading.value = false;
  }
};

API からデータを取得し、状態を更新します。

エラーハンドリングも含めることで、堅牢な実装になりますね。

アクション:ユーザー追加

typescript// アクション:新規ユーザーを追加
const addUser = async (user: Omit<User, 'id'>) => {
  isLoading.value = true;
  error.value = null;

  try {
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(user),
    });

    if (!response.ok) throw new Error('Failed to add user');

    const newUser = await response.json();
    users.value.push(newUser);
  } catch (e) {
    error.value =
      e instanceof Error ? e.message : 'Unknown error';
  } finally {
    isLoading.value = false;
  }
};

新規ユーザーの追加処理を実装しています。

エクスポート

typescript  // 公開する状態とアクション
  return {
    users,
    isLoading,
    error,
    adminUsers,
    userCount,
    fetchUsers,
    addUser
  }
})

ストアから公開する状態とアクションを return で指定します。

ui 層の実装例:useModalStore

ui 層では、画面の表示制御を管理します。

モーダルの開閉を管理する useModalStore を実装してみましょう。

インポートと状態定義

typescript// src/stores/ui/useModalStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useModalStore = defineStore('modal', () => {
  // 状態:モーダルの開閉状態
  const isOpen = ref(false)

  // 状態:モーダルのタイプ
  const modalType = ref<'info' | 'confirm' | 'alert' | null>(null)

  // 状態:モーダルに表示するメッセージ
  const message = ref('')

モーダルの開閉状態とタイプ、表示メッセージを管理します。

算出プロパティ

typescript// 算出:確認モーダルかどうか
const isConfirmModal = computed(
  () => modalType.value === 'confirm'
);

現在のモーダルタイプを判定する算出プロパティです。

アクション:モーダル操作

typescript// アクション:モーダルを開く
const openModal = (
  type: 'info' | 'confirm' | 'alert',
  msg: string
) => {
  modalType.value = type;
  message.value = msg;
  isOpen.value = true;
};

// アクション:モーダルを閉じる
const closeModal = () => {
  isOpen.value = false;
  // アニメーション完了後に状態をリセット
  setTimeout(() => {
    modalType.value = null;
    message.value = '';
  }, 300);
};

モーダルの開閉処理を実装しています。

閉じる際にはアニメーションを考慮して、少し遅延させてから状態をリセットすると良いでしょう。

エクスポート

typescript  return {
    isOpen,
    modalType,
    message,
    isConfirmModal,
    openModal,
    closeModal
  }
})

ui 層から domain 層を参照する例:useFilterStore

ui 層は domain 層を参照できます。

商品のフィルタリング状態を管理する useFilterStore を実装してみましょう。

インポートと依存ストアの読み込み

typescript// src/stores/ui/useFilterStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useProductStore } from '@/stores/domain/useProductStore';

domain 層の useProductStore をインポートします。

これにより、ui 層から domain 層のデータを参照できます。

状態とフィルター条件の定義

typescriptexport const useFilterStore = defineStore('filter', () => {
  // domain ストアを取得
  const productStore = useProductStore()

  // 状態:検索キーワード
  const keyword = ref('')

  // 状態:カテゴリーフィルター
  const selectedCategory = ref<string | null>(null)

  // 状態:価格範囲
  const priceRange = ref({ min: 0, max: 10000 })

フィルター条件を状態として管理します。

算出プロパティ:フィルタリング結果

typescript// 算出:フィルタリングされた商品リスト
const filteredProducts = computed(() => {
  let result = productStore.products;

  // キーワードでフィルタリング
  if (keyword.value) {
    result = result.filter((p) =>
      p.name
        .toLowerCase()
        .includes(keyword.value.toLowerCase())
    );
  }

  // カテゴリーでフィルタリング
  if (selectedCategory.value) {
    result = result.filter(
      (p) => p.category === selectedCategory.value
    );
  }

  // 価格範囲でフィルタリング
  result = result.filter(
    (p) =>
      p.price >= priceRange.value.min &&
      p.price <= priceRange.value.max
  );

  return result;
});

domain 層の商品データに対して、ui 層のフィルター条件を適用しています。

このように、ui 層から domain 層を参照することで、ビジネスロジックと UI ロジックを分離しつつ、柔軟な実装が可能になりますね。

アクション:フィルターのリセット

typescript  // アクション:フィルターをリセット
  const resetFilter = () => {
    keyword.value = ''
    selectedCategory.value = null
    priceRange.value = { min: 0, max: 10000 }
  }

  return {
    keyword,
    selectedCategory,
    priceRange,
    filteredProducts,
    resetFilter
  }
})

フィルター条件を初期状態に戻すアクションです。

session 層の実装例:useAuthStore

session 層では、認証情報とセッション管理を行います。

ログイン状態を管理する useAuthStore を実装してみましょう。

インポートと型定義

typescript// src/stores/session/useAuthStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

// 認証ユーザーの型定義
interface AuthUser {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

状態管理とトークン

typescriptexport const useAuthStore = defineStore('auth', () => {
  // 状態:認証トークン
  const token = ref<string | null>(localStorage.getItem('auth_token'))

  // 状態:現在のユーザー
  const currentUser = ref<AuthUser | null>(null)

  // 状態:ローディング状態
  const isLoading = ref(false)

認証トークンは localStorage から初期値を読み込みます。

算出プロパティ:認証状態

typescript// 算出:ログイン済みかどうか
const isAuthenticated = computed(
  () => !!token.value && !!currentUser.value
);

// 算出:管理者かどうか
const isAdmin = computed(
  () => currentUser.value?.role === 'admin'
);

ログイン状態や権限を判定する算出プロパティです。

アクション:ログイン処理

typescript// アクション:ログイン
const login = async (email: string, password: string) => {
  isLoading.value = true;

  try {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) throw new Error('Login failed');

    const data = await response.json();

    // トークンを保存
    token.value = data.token;
    localStorage.setItem('auth_token', data.token);

    // ユーザー情報を保存
    currentUser.value = data.user;
  } catch (e) {
    console.error('Login error:', e);
    throw e;
  } finally {
    isLoading.value = false;
  }
};

ログイン処理では、API からトークンとユーザー情報を取得し、localStorage に保存します。

アクション:ログアウト処理

typescript// アクション:ログアウト
const logout = () => {
  token.value = null;
  currentUser.value = null;
  localStorage.removeItem('auth_token');
};

ログアウト時には、状態と localStorage をクリアします。

アクション:ユーザー情報の取得

typescript  // アクション:現在のユーザー情報を取得
  const fetchCurrentUser = async () => {
    if (!token.value) return

    isLoading.value = true

    try {
      const response = await fetch('/api/auth/me', {
        headers: { 'Authorization': `Bearer ${token.value}` }
      })

      if (!response.ok) throw new Error('Failed to fetch user')

      const data = await response.json()
      currentUser.value = data
    } catch (e) {
      console.error('Fetch user error:', e)
      // トークンが無効な場合はログアウト
      logout()
    } finally {
      isLoading.value = false
    }
  }

  return {
    token,
    currentUser,
    isLoading,
    isAuthenticated,
    isAdmin,
    login,
    logout,
    fetchCurrentUser
  }
})

トークンが存在する場合、ユーザー情報を取得します。

トークンが無効な場合は自動的にログアウトする仕組みです。

コンポーネントでの使用例

実際にコンポーネントで三層のストアを使用する例を見てみましょう。

セットアップと初期化

vue<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/domain/useUserStore'
import { useModalStore } from '@/stores/ui/useModalStore'
import { useAuthStore } from '@/stores/session/useAuthStore'

// 各ストアを取得
const userStore = useUserStore()
const modalStore = useModalStore()
const authStore = useAuthStore()

3 つのレイヤーからストアをインポートします。

初期化処理

vue// コンポーネントマウント時の処理 onMounted(async () => { //
session:認証状態を確認 if (!authStore.isAuthenticated) {
modalStore.openModal('alert', 'ログインが必要です') return }
// domain:ユーザーデータを取得 await userStore.fetchUsers()
})

認証状態を確認してから、ビジネスデータを取得します。

イベントハンドラー

vue// ユーザー削除の確認
const confirmDelete = (userId: number) => {
  // ui:確認モーダルを表示
  modalStore.openModal('confirm', '本当に削除しますか?')

  // モーダルの確認ボタンがクリックされた場合の処理
  // (実際は modalStore にコールバック機能を追加する必要があります)
}
</script>

各レイヤーのストアを適切に使い分けることで、責務が明確になります。

テンプレート部分

vue<template>
  <div>
    <!-- session:認証情報の表示 -->
    <div v-if="authStore.isAuthenticated">
      <p>
        ようこそ、{{ authStore.currentUser?.name }} さん
      </p>
      <p v-if="authStore.isAdmin">管理者権限でログイン中</p>
    </div>

    <!-- domain:ユーザーリストの表示 -->
    <div v-if="userStore.isLoading">読み込み中...</div>
    <ul v-else>
      <li v-for="user in userStore.users" :key="user.id">
        {{ user.name }} ({{ user.email }})
      </li>
    </ul>

    <!-- ui:モーダルの表示 -->
    <div v-if="modalStore.isOpen" class="modal">
      <p>{{ modalStore.message }}</p>
      <button @click="modalStore.closeModal">閉じる</button>
    </div>
  </div>
</template>

テンプレート内でも、各レイヤーのストアを明確に使い分けています。

三層パターンのデータフロー図

最後に、三層パターン全体のデータフローを図で確認しましょう。

mermaidsequenceDiagram
  participant C as コンポーネント
  participant U as ui ストア
  participant D as domain ストア
  participant S as session ストア
  participant API as Backend API

  C->>S: 認証状態確認
  S-->>C: 認証済み

  C->>D: データ取得リクエスト
  D->>API: API 呼び出し
  API-->>D: データ返却
  D-->>C: データ更新通知

  C->>U: モーダル表示リクエスト
  U-->>C: モーダル状態更新

  C->>U: フィルター適用
  U->>D: domain データ参照
  D-->>U: データ提供
  U-->>C: フィルタリング結果

この図が示すように、各レイヤーは明確な役割を持ち、適切に連携しています。

図で理解できる要点

  • コンポーネントは各レイヤーに対して役割に応じたリクエストを送る
  • domain はビジネスロジックと API 通信を担当
  • ui は domain のデータを参照しながら表示制御を行う
  • session は独立して認証状態を管理

まとめ

Pinia のストアを domainuisession の三層に分割することで、大規模アプリケーションでも見通しの良い状態管理が実現できます。

三層パターンのポイント

  • domain:ビジネスロジックとデータ管理(独立)
  • ui:UI 状態と表示制御(domain と session を参照可)
  • session:認証とセッション管理(独立)

この設計により、以下のメリットが得られます。

  1. 責務の明確化:どこに何を置くべきか迷わない
  2. 再利用性の向上:ビジネスロジックを複数の画面で共有できる
  3. テストの容易性:レイヤーごとに独立したテストが書ける
  4. 保守性の向上:変更の影響範囲が限定される

ぜひこのテンプレートを活用して、チーム開発でも一貫性のある Pinia ストア設計を実現してください。

最初は少し手間に感じるかもしれませんが、プロジェクトが成長するにつれて、この設計の恩恵を実感できるはずです。

関連リンク