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

大規模な Vue.js アプリケーションで Pinia を使っていると、「このデータはどこに置くべき?」と迷うことがありますよね。
ストア設計を明確にしておかないと、後々メンテナンスが困難になり、状態管理が複雑化してしまいます。そこで今回は、Pinia のストアを domain(ドメイン)、ui(UI)、session(セッション) の三層に分割する設計パターンをテンプレート付きでご紹介します。
この記事を読むことで、チーム開発でも一貫性のある状態管理が実現でき、コードの見通しが格段に良くなります。
早見表:三層パターン適用ガイド
# | レイヤー名 | 役割 | 主な状態例 | 主なアクション例 | 依存関係 |
---|---|---|---|---|---|
1 | domain | ビジネスロジック・データ管理 | ユーザー情報、商品リスト、投稿データ | データ取得、CRUD 操作、バリデーション | なし(独立) |
2 | ui | UI 状態・表示制御 | モーダル表示状態、ローディング、選択中のタブ | モーダル開閉、ローディング切替、フィルター適用 | domain に依存可 |
3 | session | セッション・認証情報 | ログイン状態、トークン、ユーザー設定 | ログイン、ログアウト、権限確認 | なし(独立) |
適用判断チェックリスト
# | 状態の種類 | 分類先 | 判断基準 |
---|---|---|---|
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
このような循環参照は、デバッグを困難にし、予期しないバグを引き起こす原因となります。
図で理解できる要点
- 循環参照が発生すると、どこから状態を追うべきか分からなくなる
- 変更の影響範囲が予測できず、バグの温床になる
解決策
三層パターンによるストア分割
三層パターンでは、ストアを domain、ui、session の 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 の ref
、computed
をインポートします。
ユーザー型の定義
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 のストアを domain、ui、session の三層に分割することで、大規模アプリケーションでも見通しの良い状態管理が実現できます。
三層パターンのポイント
- domain:ビジネスロジックとデータ管理(独立)
- ui:UI 状態と表示制御(domain と session を参照可)
- session:認証とセッション管理(独立)
この設計により、以下のメリットが得られます。
- 責務の明確化:どこに何を置くべきか迷わない
- 再利用性の向上:ビジネスロジックを複数の画面で共有できる
- テストの容易性:レイヤーごとに独立したテストが書ける
- 保守性の向上:変更の影響範囲が限定される
ぜひこのテンプレートを活用して、チーム開発でも一貫性のある Pinia ストア設計を実現してください。
最初は少し手間に感じるかもしれませんが、プロジェクトが成長するにつれて、この設計の恩恵を実感できるはずです。
関連リンク
- article
Pinia ストア分割テンプレ集:domain/ui/session の三層パターン
- article
Pinia をフレームワークレスで SSR:Nitro/Express 直結の同形レンダリング
- article
Pinia と TanStack Query の使い分けを徹底検証:サーバー/クライアント状態の最適解
- article
Pinia で状態が更新されない?参照の再利用・シャロー比較・getter 依存の落とし穴
- article
Pinia アーキテクチャ超図解:リアクティビティとストアの舞台裏を一枚で理解
- article
Pinia × TypeScript:型安全なストア設計入門
- article
Vitest `vi` API 技術チートシート:`mock` / `fn` / `spyOn` / `advanceTimersByTime` 一覧
- article
Pinia ストア分割テンプレ集:domain/ui/session の三層パターン
- article
Obsidian Markdown 拡張チートシート:Callout/埋め込み/内部リンク完全網羅
- article
Micro Frontends 設計:`vite-plugin-federation` で分割可能な UI を構築
- article
TypeScript 公開 API の型設計術:`export type`/`interface`/`class`の責務分担と境界設計
- article
Nuxt nuxi コマンド速見表:プロジェクト作成からモジュール公開まで
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来