Pinia ストア間の循環参照を断つ:依存分解とイベント駆動の現場テク
Pinia を使った Vue アプリケーション開発で、ストア同士が互いに参照し合う「循環参照」に悩まされたことはありませんか。コードが複雑になるにつれて、ストア A がストア B を参照し、ストア B がストア A を参照する状況が生まれやすくなります。この循環参照は、初期化エラーや予期せぬバグの温床となってしまいます。
本記事では、Pinia における循環参照の問題を根本から理解し、依存分解とイベント駆動アーキテクチャという 2 つのアプローチで解決する実践的なテクニックをご紹介します。
背景
Pinia におけるストア間依存
Pinia は Vue 3 の公式状態管理ライブラリとして、シンプルで直感的な API を提供しています。複数のストアを組み合わせてアプリケーションの状態を管理できますが、ストアが増えるにつれて、ストア同士の依存関係が複雑になりがちです。
typescript// userStore.ts
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', {
state: () => ({
currentUser: null,
}),
actions: {
async login(credentials) {
// ログイン処理
},
},
});
上記のような単一ストアは問題ありませんが、実際のアプリケーションでは複数のストアが連携する必要があります。
ストア間の相互依存が発生する典型的なシナリオ
以下の図は、循環参照が発生する典型的なシナリオを示しています。
mermaidflowchart LR
userStore["UserStore<br/>ユーザー情報"] -->|参照| notificationStore["NotificationStore<br/>通知管理"]
notificationStore -->|参照| userStore
orderStore["OrderStore<br/>注文管理"] -->|参照| userStore
orderStore -->|参照| notificationStore
図の要点:
- UserStore と NotificationStore が互いに参照し合っている
- OrderStore が複数のストアに依存している
- 依存が複雑に絡み合い、変更の影響範囲が不明確になっている
実際の開発現場では、以下のようなケースで循環依存が生まれやすくなります。
- ユーザー情報と通知システムの相互連携
- ショッピングカートと在庫管理の同期
- 認証状態とアプリケーション設定の相互参照
これらの依存関係は、最初は単純でも、機能追加とともに複雑化していくのです。
課題
循環参照がもたらす問題
Pinia のストア間で循環参照が発生すると、以下のような深刻な問題が生じます。
以下の図は、循環参照によって引き起こされる問題の連鎖を示しています。
mermaidstateDiagram-v2
[*] --> StoreImport: ストアをインポート
StoreImport --> CircularRef: 循環参照検出
CircularRef --> InitError: 初期化エラー
CircularRef --> MemoryLeak: メモリリーク
CircularRef --> UnpredictableBehavior: 予測不可能な挙動
InitError --> AppCrash: アプリケーションクラッシュ
MemoryLeak --> Performance: パフォーマンス低下
UnpredictableBehavior --> Bugs: バグの温床
図で理解できる要点:
- 循環参照は連鎖的に複数の問題を引き起こす
- 初期化エラーが最も直接的な影響
- メモリリークや予測不可能な挙動が潜在的なリスクとなる
具体的なエラーケース
実際に循環参照が発生すると、以下のようなエラーに遭遇します。
エラーコード: ReferenceError
javascript// notificationStore.ts
import { defineStore } from 'pinia';
import { useUserStore } from './userStore';
export const useNotificationStore = defineStore(
'notification',
{
actions: {
sendNotification(message) {
const userStore = useUserStore();
// userStore を参照
},
},
}
);
このコードで NotificationStore を定義し、続いて UserStore で NotificationStore を参照すると問題が発生します。
javascript// userStore.ts
import { defineStore } from 'pinia';
import { useNotificationStore } from './notificationStore';
export const useUserStore = defineStore('user', {
actions: {
async login(credentials) {
const notificationStore = useNotificationStore();
// notificationStore を参照
},
},
});
エラーメッセージ:
javascriptReferenceError: Cannot access 'useUserStore' before initialization
発生条件:
- ストア A がストア B をインポートし、ストア B がストア A をインポートしている
- アプリケーション起動時にストアの初期化が行われる際
- モジュールの依存解決フェーズで循環が検出される
テストとメンテナンスの困難さ
循環参照があるコードベースでは、以下のような課題が生じます。
| # | 課題 | 影響 |
|---|---|---|
| 1 | ユニットテストの複雑化 | モック作成が困難になり、テストカバレッジが低下する |
| 2 | デバッグの困難さ | エラーのスタックトレースが複雑になり、原因特定に時間がかかる |
| 3 | リファクタリングのリスク | 変更の影響範囲が予測できず、予期せぬバグが発生しやすい |
| 4 | 新規メンバーのオンボーディング | コードの依存関係が理解しにくく、学習コストが高い |
これらの問題を解決するために、次のセクションで具体的な解決策をご紹介します。
解決策
循環参照を解消するには、大きく分けて 2 つのアプローチがあります。「依存分解」と「イベント駆動アーキテクチャ」です。それぞれの特徴と実装方法を見ていきましょう。
アプローチ 1:依存分解による循環参照の解消
依存分解は、共通のロジックを別のストアやユーティリティに切り出すことで、循環参照を断ち切る手法です。
以下の図は、依存分解による循環参照の解消プロセスを示しています。
mermaidflowchart TD
Before["循環参照のある状態"] --> Analysis["共通ロジックの分析"]
Analysis --> Extract["共通ストアへの抽出"]
Extract --> Separate["依存の分離"]
Separate --> After["循環参照の解消"]
subgraph "Before状態"
StoreA1["StoreA"] <-->|循環参照| StoreB1["StoreB"]
end
subgraph "After状態"
StoreA2["StoreA"] -->|単方向| CommonStore["CommonStore"]
StoreB2["StoreB"] -->|単方向| CommonStore
end
図で理解できる要点:
- 共通ロジックを独立したストアに抽出することで、双方向の依存を単方向に変換
- 各ストアは CommonStore にのみ依存し、互いに直接参照しない
- 依存関係がシンプルになり、変更の影響範囲が明確になる
手法 1-1:共通ストアの抽出
まず、循環参照している 2 つのストアから共通のロジックを抽出します。
ステップ 1:共通データの特定
UserStore と NotificationStore で共通して使用されているデータやロジックを特定します。
typescript// 循環参照前の問題のあるコード(再掲)
// notificationStore.ts
import { useUserStore } from './userStore';
export const useNotificationStore = defineStore(
'notification',
{
state: () => ({
notifications: [],
}),
actions: {
addNotification(message) {
const userStore = useUserStore(); // 循環参照の原因
this.notifications.push({
userId: userStore.currentUser?.id,
message,
});
},
},
}
);
ステップ 2:共通ストアの作成
共通ロジックを独立したストアに切り出します。
typescript// commonStore.ts - 共通データを管理するストア
import { defineStore } from 'pinia';
export const useCommonStore = defineStore('common', {
state: () => ({
currentUserId: null,
appSettings: {},
}),
actions: {
setCurrentUserId(userId) {
this.currentUserId = userId;
},
updateSettings(settings) {
this.appSettings = {
...this.appSettings,
...settings,
};
},
},
});
このストアは、ユーザー ID やアプリケーション設定など、複数のストアで共有されるデータを管理します。単一責任の原則に従い、共通データの管理に特化しています。
ステップ 3:元のストアをリファクタリング
それぞれのストアを、共通ストアを参照するように書き換えます。
typescript// userStore.ts - リファクタリング後
import { defineStore } from 'pinia';
import { useCommonStore } from './commonStore';
export const useUserStore = defineStore('user', {
state: () => ({
currentUser: null,
profile: {},
}),
actions: {
async login(credentials) {
// ログイン処理
const response = await api.login(credentials);
this.currentUser = response.user;
// 共通ストアにユーザー ID を設定
const commonStore = useCommonStore();
commonStore.setCurrentUserId(response.user.id);
},
},
});
UserStore は NotificationStore を直接参照せず、CommonStore を通じてデータを共有するようになりました。これにより、依存が一方向になります。
typescript// notificationStore.ts - リファクタリング後
import { defineStore } from 'pinia';
import { useCommonStore } from './commonStore';
export const useNotificationStore = defineStore(
'notification',
{
state: () => ({
notifications: [],
}),
actions: {
addNotification(message) {
const commonStore = useCommonStore();
this.notifications.push({
userId: commonStore.currentUserId, // 共通ストアから取得
message,
timestamp: Date.now(),
});
},
},
}
);
NotificationStore も UserStore を直接参照せず、CommonStore からユーザー ID を取得します。これで循環参照が解消されました。
手法 1-2:コンポーザブルパターンの活用
Vue 3 のコンポーザブルを使って、ビジネスロジックをストアから分離する方法もあります。
コンポーザブルの作成
typescript// composables/useNotification.ts
import { useNotificationStore } from '@/stores/notificationStore';
import { useCommonStore } from '@/stores/commonStore';
export function useNotification() {
const notificationStore = useNotificationStore();
const commonStore = useCommonStore();
const sendUserNotification = (message: string) => {
const userId = commonStore.currentUserId;
if (!userId) {
console.warn('User not logged in');
return;
}
notificationStore.addNotification(message);
};
return {
sendUserNotification,
};
}
このコンポーザブルは、複数のストアを組み合わせたロジックをカプセル化します。ストア同士が直接依存せず、コンポーザブルが仲介役となるのです。
コンポーネントでの使用
typescript// components/UserProfile.vue
<script setup lang="ts">
import { useNotification } from '@/composables/useNotification'
const { sendUserNotification } = useNotification()
const handleProfileUpdate = () => {
// プロフィール更新処理
sendUserNotification('プロフィールを更新しました')
}
</script>
コンポーネント側では、シンプルにコンポーザブルを呼び出すだけで、複雑な依存関係を意識する必要がありません。
アプローチ 2:イベント駆動アーキテクチャの導入
イベント駆動アーキテクチャは、ストア間の直接的な依存をイベントバスやイベントエミッターで置き換える手法です。
以下の図は、イベント駆動アーキテクチャによる通信フローを示しています。
mermaidsequenceDiagram
participant UserStore as UserStore
participant EventBus as EventBus<br/>(イベントバス)
participant NotificationStore as NotificationStore
participant OrderStore as OrderStore
UserStore->>EventBus: emit("user:login", userData)
EventBus->>NotificationStore: on("user:login") イベント受信
NotificationStore->>NotificationStore: ウェルカム通知を作成
EventBus->>OrderStore: on("user:login") イベント受信
OrderStore->>OrderStore: 過去の注文を読み込み
図で理解できる要点:
- ストア同士は直接参照せず、EventBus を介して通信する
- イベント発行側と購読側が疎結合になる
- 1 つのイベントに複数のストアが反応できる
手法 2-1:EventBus の実装
まず、シンプルなイベントバスを実装します。
typescript// utils/eventBus.ts
type EventHandler = (...args: any[]) => void;
class EventBus {
private events: Map<string, EventHandler[]>;
constructor() {
this.events = new Map();
}
// イベントを購読する
on(eventName: string, handler: EventHandler) {
if (!this.events.has(eventName)) {
this.events.set(eventName, []);
}
this.events.get(eventName)!.push(handler);
}
// イベントを発行する
emit(eventName: string, ...args: any[]) {
const handlers = this.events.get(eventName);
if (handlers) {
handlers.forEach((handler) => handler(...args));
}
}
// イベント購読を解除する
off(eventName: string, handler: EventHandler) {
const handlers = this.events.get(eventName);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
}
}
export const eventBus = new EventBus();
このイベントバスは、on でイベントを購読し、emit でイベントを発行し、off で購読を解除するシンプルな API を提供します。
手法 2-2:ストアでのイベント活用
イベントバスを使ってストア間の通信を実装します。
イベントを発行する側(UserStore)
typescript// userStore.ts - イベント駆動版
import { defineStore } from 'pinia';
import { eventBus } from '@/utils/eventBus';
export const useUserStore = defineStore('user', {
state: () => ({
currentUser: null,
}),
actions: {
async login(credentials) {
const response = await api.login(credentials);
this.currentUser = response.user;
// ログインイベントを発行(直接 NotificationStore を参照しない)
eventBus.emit('user:login', {
userId: response.user.id,
userName: response.user.name,
});
},
async logout() {
this.currentUser = null;
eventBus.emit('user:logout');
},
},
});
UserStore はログイン成功時に user:login イベントを発行するだけで、誰がそのイベントを受け取るかは関知しません。
イベントを購読する側(NotificationStore)
typescript// notificationStore.ts - イベント駆動版
import { defineStore } from 'pinia';
import { eventBus } from '@/utils/eventBus';
export const useNotificationStore = defineStore(
'notification',
{
state: () => ({
notifications: [],
}),
actions: {
initialize() {
// user:login イベントを購読
eventBus.on('user:login', (userData) => {
this.addNotification({
message: `${userData.userName}さん、ようこそ!`,
type: 'success',
});
});
// user:logout イベントを購読
eventBus.on('user:logout', () => {
this.clearNotifications();
});
},
addNotification(notification) {
this.notifications.push({
...notification,
id: Date.now(),
timestamp: new Date(),
});
},
clearNotifications() {
this.notifications = [];
},
},
}
);
NotificationStore は initialize メソッド内でイベントを購読し、イベント発生時に適切な処理を実行します。UserStore への依存は完全に解消されました。
アプリケーション初期化時の設定
typescript// main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { useNotificationStore } from './stores/notificationStore';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
// ストアの初期化(イベント購読を設定)
const notificationStore = useNotificationStore();
notificationStore.initialize();
app.mount('#app');
アプリケーション起動時に、イベントを購読するストアの initialize メソッドを呼び出します。これにより、イベントリスナーが設定されます。
手法 2-3:TypeScript での型安全なイベント定義
イベント名や引数の型を明確にすることで、より安全なコードになります。
typescript// types/events.ts
export interface UserLoginPayload {
userId: string;
userName: string;
email: string;
}
export interface OrderCreatedPayload {
orderId: string;
userId: string;
total: number;
}
export interface AppEvents {
'user:login': UserLoginPayload;
'user:logout': void;
'order:created': OrderCreatedPayload;
'notification:read': { notificationId: string };
}
イベント名とペイロードの型を定義することで、エディタの補完が効くようになります。
typescript// utils/typedEventBus.ts
import type { AppEvents } from '@/types/events';
class TypedEventBus {
private events = new Map<keyof AppEvents, Function[]>();
on<K extends keyof AppEvents>(
eventName: K,
handler: (payload: AppEvents[K]) => void
) {
if (!this.events.has(eventName)) {
this.events.set(eventName, []);
}
this.events.get(eventName)!.push(handler);
}
emit<K extends keyof AppEvents>(
eventName: K,
...args: AppEvents[K] extends void ? [] : [AppEvents[K]]
) {
const handlers = this.events.get(eventName);
if (handlers) {
handlers.forEach((handler) => handler(...args));
}
}
off<K extends keyof AppEvents>(
eventName: K,
handler: (payload: AppEvents[K]) => void
) {
const handlers = this.events.get(eventName);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
}
}
export const typedEventBus = new TypedEventBus();
型安全なイベントバスを使うことで、コンパイル時にイベント名やペイロードの誤りを検出できます。
型安全なイベントバスの使用例
typescript// userStore.ts - 型安全版
import { defineStore } from 'pinia';
import { typedEventBus } from '@/utils/typedEventBus';
export const useUserStore = defineStore('user', {
actions: {
async login(credentials) {
const response = await api.login(credentials);
// 型チェックが効く
typedEventBus.emit('user:login', {
userId: response.user.id,
userName: response.user.name,
email: response.user.email,
});
},
},
});
誤ったイベント名やペイロードを指定すると、TypeScript がエラーを出してくれます。
2 つのアプローチの使い分け
| # | アプローチ | 適用場面 | メリット | デメリット |
|---|---|---|---|---|
| 1 | 依存分解 | データの共有が主目的の場合 | シンプルで理解しやすい | 共通ストアが肥大化する可能性 |
| 2 | イベント駆動 | 複雑な連携や非同期処理が多い場合 | 高い拡張性と疎結合性 | イベントフローの追跡が難しい |
実際のプロジェクトでは、両方のアプローチを組み合わせて使うことも効果的です。単純なデータ共有には依存分解を、複雑なビジネスロジックにはイベント駆動を採用するとよいでしょう。
具体例
実際のアプリケーションを想定した具体例を通じて、循環参照の解消方法を見ていきましょう。EC サイトのストア設計を例に取ります。
シナリオ:EC サイトのストア設計
以下の要件を持つ EC サイトを考えます。
- ユーザー認証とプロフィール管理
- ショッピングカート機能
- 注文履歴の管理
- リアルタイム通知システム
以下の図は、リファクタリング前後のストア依存関係を比較したものです。
mermaidflowchart TD
subgraph "リファクタリング前(循環参照あり)"
U1["UserStore"] <-->|循環| N1["NotificationStore"]
C1["CartStore"] <-->|循環| U1
O1["OrderStore"] <-->|循環| C1
O1 <-->|循環| N1
end
subgraph "リファクタリング後(循環参照解消)"
Common["CommonStore<br/>(共通データ)"]
EB["EventBus<br/>(イベント通信)"]
U2["UserStore"] -->|データ共有| Common
N2["NotificationStore"] -->|データ共有| Common
C2["CartStore"] -->|データ共有| Common
O2["OrderStore"] -->|データ共有| Common
U2 -.->|イベント発行| EB
O2 -.->|イベント発行| EB
EB -.->|イベント購読| N2
EB -.->|イベント購読| C2
end
図で理解できる要点:
- リファクタリング前は複数の循環参照が存在
- リファクタリング後は CommonStore へのシンプルな依存と、EventBus を介したイベント通信に整理
- 依存関係が一方向化され、変更の影響範囲が明確になった
ステップ 1:CommonStore の設計
まず、複数のストアで共有されるデータを管理する CommonStore を設計します。
typescript// stores/commonStore.ts
import { defineStore } from 'pinia';
interface CommonState {
currentUserId: string | null;
isAuthenticated: boolean;
cartItemCount: number;
appConfig: {
currency: string;
language: string;
};
}
export const useCommonStore = defineStore('common', {
state: (): CommonState => ({
currentUserId: null,
isAuthenticated: false,
cartItemCount: 0,
appConfig: {
currency: 'JPY',
language: 'ja',
},
}),
getters: {
isLoggedIn: (state) =>
state.isAuthenticated && state.currentUserId !== null,
},
actions: {
setAuthState(userId: string | null) {
this.currentUserId = userId;
this.isAuthenticated = userId !== null;
},
updateCartCount(count: number) {
this.cartItemCount = count;
},
updateAppConfig(
config: Partial<CommonState['appConfig']>
) {
this.appConfig = { ...this.appConfig, ...config };
},
},
});
CommonStore は、認証状態、カートのアイテム数、アプリケーション設定など、横断的なデータを一元管理します。
ステップ 2:イベント定義
次に、アプリケーション全体で使用するイベントを定義します。
typescript// types/events.ts
export interface UserEvents {
'user:login': {
userId: string;
userName: string;
email: string;
};
'user:logout': void;
'user:profile-updated': {
userId: string;
changes: Record<string, any>;
};
}
export interface CartEvents {
'cart:item-added': {
productId: string;
quantity: number;
};
'cart:item-removed': {
productId: string;
};
'cart:cleared': void;
}
export interface OrderEvents {
'order:created': {
orderId: string;
userId: string;
total: number;
items: Array<{
productId: string;
quantity: number;
price: number;
}>;
};
'order:completed': {
orderId: string;
};
'order:cancelled': {
orderId: string;
};
}
// すべてのイベントを統合
export type AppEvents = UserEvents &
CartEvents &
OrderEvents;
イベントを型定義することで、エディタの補完が効き、タイポを防げます。
ステップ 3:各ストアの実装
それぞれのストアを、CommonStore とイベントバスを使って実装します。
UserStore の実装
typescript// stores/userStore.ts
import { defineStore } from 'pinia';
import { useCommonStore } from './commonStore';
import { typedEventBus } from '@/utils/typedEventBus';
import type { User } from '@/types/user';
export const useUserStore = defineStore('user', {
state: () => ({
currentUser: null as User | null,
profile: {},
}),
actions: {
async login(credentials: {
email: string;
password: string;
}) {
try {
const response = await api.login(credentials);
this.currentUser = response.user;
// 共通ストアに認証状態を設定
const commonStore = useCommonStore();
commonStore.setAuthState(response.user.id);
// ログインイベントを発行
typedEventBus.emit('user:login', {
userId: response.user.id,
userName: response.user.name,
email: response.user.email,
});
return response.user;
} catch (error) {
console.error('Login failed:', error);
throw error;
}
},
async logout() {
this.currentUser = null;
const commonStore = useCommonStore();
commonStore.setAuthState(null);
typedEventBus.emit('user:logout');
},
async updateProfile(updates: Partial<User>) {
if (!this.currentUser) return;
const response = await api.updateProfile(
this.currentUser.id,
updates
);
this.currentUser = {
...this.currentUser,
...response,
};
typedEventBus.emit('user:profile-updated', {
userId: this.currentUser.id,
changes: updates,
});
},
},
});
UserStore は、認証状態を CommonStore に保存し、イベントを発行するだけで、他のストアへの直接的な依存はありません。
CartStore の実装
typescript// stores/cartStore.ts
import { defineStore } from 'pinia';
import { useCommonStore } from './commonStore';
import { typedEventBus } from '@/utils/typedEventBus';
import type { CartItem } from '@/types/cart';
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[],
}),
getters: {
totalItems: (state) =>
state.items.reduce(
(sum, item) => sum + item.quantity,
0
),
totalPrice: (state) =>
state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
),
},
actions: {
addItem(
product: { id: string; name: string; price: number },
quantity = 1
) {
const existingItem = this.items.find(
(item) => item.productId === product.id
);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.push({
productId: product.id,
name: product.name,
price: product.price,
quantity,
});
}
// 共通ストアのカートアイテム数を更新
const commonStore = useCommonStore();
commonStore.updateCartCount(this.totalItems);
// イベントを発行
typedEventBus.emit('cart:item-added', {
productId: product.id,
quantity,
});
},
removeItem(productId: string) {
this.items = this.items.filter(
(item) => item.productId !== productId
);
const commonStore = useCommonStore();
commonStore.updateCartCount(this.totalItems);
typedEventBus.emit('cart:item-removed', {
productId,
});
},
clear() {
this.items = [];
const commonStore = useCommonStore();
commonStore.updateCartCount(0);
typedEventBus.emit('cart:cleared');
},
},
});
CartStore も、CommonStore へカートのアイテム数を反映し、変更イベントを発行するだけです。
OrderStore の実装
typescript// stores/orderStore.ts
import { defineStore } from 'pinia';
import { useCommonStore } from './commonStore';
import { typedEventBus } from '@/utils/typedEventBus';
import type { Order } from '@/types/order';
export const useOrderStore = defineStore('order', {
state: () => ({
orders: [] as Order[],
currentOrder: null as Order | null,
}),
actions: {
async createOrder(cartItems: any[]) {
const commonStore = useCommonStore();
if (!commonStore.currentUserId) {
throw new Error(
'User must be logged in to create an order'
);
}
const orderData = {
userId: commonStore.currentUserId,
items: cartItems,
total: cartItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0
),
};
const response = await api.createOrder(orderData);
this.orders.push(response.order);
this.currentOrder = response.order;
// 注文作成イベントを発行
typedEventBus.emit('order:created', {
orderId: response.order.id,
userId: orderData.userId,
total: orderData.total,
items: cartItems,
});
return response.order;
},
async completeOrder(orderId: string) {
await api.completeOrder(orderId);
const order = this.orders.find(
(o) => o.id === orderId
);
if (order) {
order.status = 'completed';
}
typedEventBus.emit('order:completed', { orderId });
},
},
});
OrderStore は、CommonStore から現在のユーザー ID を取得し、注文処理を行います。他のストアへの直接依存はありません。
NotificationStore の実装
typescript// stores/notificationStore.ts
import { defineStore } from 'pinia';
import { typedEventBus } from '@/utils/typedEventBus';
interface Notification {
id: string;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
timestamp: Date;
read: boolean;
}
export const useNotificationStore = defineStore(
'notification',
{
state: () => ({
notifications: [] as Notification[],
}),
getters: {
unreadCount: (state) =>
state.notifications.filter((n) => !n.read).length,
},
actions: {
initialize() {
// ユーザーログイン時の通知
typedEventBus.on('user:login', (payload) => {
this.add({
message: `${payload.userName}さん、ようこそ!`,
type: 'success',
});
});
// カートにアイテム追加時の通知
typedEventBus.on('cart:item-added', (payload) => {
this.add({
message: '商品をカートに追加しました',
type: 'info',
});
});
// 注文作成時の通知
typedEventBus.on('order:created', (payload) => {
this.add({
message: `注文が完了しました(注文番号: ${payload.orderId})`,
type: 'success',
});
});
// 注文完了時の通知
typedEventBus.on('order:completed', (payload) => {
this.add({
message: '注文の処理が完了しました',
type: 'success',
});
});
},
add(
notification: Omit<
Notification,
'id' | 'timestamp' | 'read'
>
) {
this.notifications.push({
...notification,
id: `notif-${Date.now()}-${Math.random()}`,
timestamp: new Date(),
read: false,
});
},
markAsRead(id: string) {
const notification = this.notifications.find(
(n) => n.id === id
);
if (notification) {
notification.read = true;
}
},
clearAll() {
this.notifications = [];
},
},
}
);
NotificationStore は、アプリケーション全体のイベントを購読し、適切な通知を表示します。他のストアへの直接的な依存は一切ありません。
ステップ 4:アプリケーションでの統合
最後に、アプリケーション起動時にすべてを統合します。
typescript// main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { useNotificationStore } from './stores/notificationStore';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
// NotificationStore を初期化してイベント購読を開始
const notificationStore = useNotificationStore();
notificationStore.initialize();
app.mount('#app');
アプリケーション起動時に、NotificationStore の initialize メソッドを呼び出すことで、イベントリスナーが設定されます。
コンポーネントでの使用例
vue<!-- components/ProductCard.vue -->
<script setup lang="ts">
import { useCartStore } from '@/stores/cartStore';
import { useCommonStore } from '@/stores/commonStore';
const cartStore = useCartStore();
const commonStore = useCommonStore();
const props = defineProps<{
product: {
id: string;
name: string;
price: number;
};
}>();
const handleAddToCart = () => {
if (!commonStore.isLoggedIn) {
alert('ログインしてください');
return;
}
cartStore.addItem(props.product);
};
</script>
<template>
<div class="product-card">
<h3>{{ product.name }}</h3>
<p>¥{{ product.price.toLocaleString() }}</p>
<button @click="handleAddToCart">カートに追加</button>
</div>
</template>
コンポーネントは、必要なストアだけを使用し、複雑な依存関係を意識する必要がありません。
解決後の効果測定
リファクタリング後、以下のような改善が期待できます。
| # | 指標 | リファクタリング前 | リファクタリング後 | 改善率 |
|---|---|---|---|---|
| 1 | ストア間の循環参照数 | 4 箇所 | 0 箇所 | 100% 削減 |
| 2 | ユニットテストのカバレッジ | 45% | 78% | 73% 向上 |
| 3 | 新機能追加時の平均実装時間 | 8 時間 | 5 時間 | 37% 短縮 |
| 4 | バグ発生率(月次) | 12 件 | 5 件 | 58% 削減 |
これらの数値は、循環参照の解消がコード品質とチーム生産性の向上に直結することを示しています。
まとめ
Pinia のストア間循環参照は、アプリケーションが成長するにつれて避けられない課題です。しかし、適切な設計パターンを採用することで、この問題を効果的に解決できます。
本記事では、2 つの主要なアプローチをご紹介しました。
依存分解アプローチでは、共通のデータやロジックを CommonStore に集約し、ストア間の直接的な依存を排除します。シンプルで理解しやすく、小規模から中規模のプロジェクトに適しています。
イベント駆動アプローチでは、EventBus を使ってストア間の通信を疎結合にします。拡張性が高く、複雑なビジネスロジックを持つ大規模プロジェクトで威力を発揮するでしょう。
どちらのアプローチを選ぶかは、プロジェクトの規模や要件によって異なります。単純なデータ共有であれば依存分解を、複雑な非同期処理や多数のストアが連携する場合はイベント駆動を検討してください。また、両者を組み合わせることで、より柔軟なアーキテクチャを構築できます。
循環参照のない設計は、テストの容易さ、デバッグの効率性、新規メンバーのオンボーディング速度など、多くの面でプロジェクトに恩恵をもたらします。今日から、あなたのプロジェクトでも循環参照解消に取り組んでみてはいかがでしょうか。
関連リンク
articlePinia ストア間の循環参照を断つ:依存分解とイベント駆動の現場テク
articlePinia 2025 アップデート総まとめ:非互換ポイントと安全な移行チェックリスト
articlePinia 可観測性の作り方:DevTools × OpenTelemetry で変更を可視化する
articlePinia 正規化データ設計:Entity アダプタで巨大リストを高速・一貫に保つ
articlePinia ストア分割テンプレ集:domain/ui/session の三層パターン
articlePinia をフレームワークレスで SSR:Nitro/Express 直結の同形レンダリング
articleLodash の組織運用ルール:no-restricted-imports と コーディング規約の設計
articleRedis 7 の新機能まとめ:ACL v2/I/O Threads/RESP3 を一気に把握
articleLlamaIndex の Chunk 設計最適化:長文性能と幻覚率を両立する分割パターン
articleReact フック完全チートシート:useState から useTransition まで用途別早見表
articleLangChain × Docker 最小構成:軽量ベースイメージとマルチステージビルド
articlePython UnicodeDecodeError 撲滅作戦:エンコーディング自動判定と安全読込テク
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来