T-CREATOR

Pinia ストア間の循環参照を断つ:依存分解とイベント駆動の現場テク

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 を使ってストア間の通信を疎結合にします。拡張性が高く、複雑なビジネスロジックを持つ大規模プロジェクトで威力を発揮するでしょう。

どちらのアプローチを選ぶかは、プロジェクトの規模や要件によって異なります。単純なデータ共有であれば依存分解を、複雑な非同期処理や多数のストアが連携する場合はイベント駆動を検討してください。また、両者を組み合わせることで、より柔軟なアーキテクチャを構築できます。

循環参照のない設計は、テストの容易さ、デバッグの効率性、新規メンバーのオンボーディング速度など、多くの面でプロジェクトに恩恵をもたらします。今日から、あなたのプロジェクトでも循環参照解消に取り組んでみてはいかがでしょうか。

関連リンク