T-CREATOR

Pinia アクション設計 50 レシピ:リトライ・デバウンス・キャンセル・同時実行制御

Pinia アクション設計 50 レシピ:リトライ・デバウンス・キャンセル・同時実行制御

Pinia を使った状態管理では、アクションの設計が重要です。特に、API 呼び出しや非同期処理を扱う場合、リトライ、デバウンス、キャンセル、同時実行制御などの高度なパターンが求められます。

本記事では、実務で役立つ Pinia アクション設計のレシピを 50 個厳選してご紹介します。初心者の方でも理解できるよう、具体的なコード例と共に段階的に解説していきますので、ぜひ最後までお読みください。

50 レシピ早見表

以下は、本記事で紹介する全 50 レシピの一覧です。目的に応じて最適なパターンを素早く見つけられます。

#レシピ名カテゴリ難易度説明主な用途
1基本的なリトライリトライ制御★☆☆固定回数リトライネットワークエラー対処
2指数バックオフリトライ制御★★☆待機時間を徐々に増加サーバー過負荷時の再試行
3ジッターありリトライリトライ制御★★☆ランダムな待機時間を追加リクエスト集中の分散
4条件付きリトライリトライ制御★★☆エラー種別で判断エラータイプ別処理
5タイムアウト付きリトライリトライ制御★★★制限時間内でリトライ長時間処理の制御
6段階的リトライリトライ制御★★☆戦略を段階的に変更複雑なリトライロジック
7サーキットブレーカーリトライ制御★★★連続失敗時に停止システム保護
8フォールバック付きリトライリトライ制御★★☆失敗時に代替処理可用性向上
9リトライ状態の可視化リトライ制御★★☆UI にリトライ情報を表示ユーザーフィードバック
10カスタマイズ可能なリトライリトライ制御★★★設定で動作を変更柔軟なリトライ制御
11基本的なデバウンスデバウンス・スロットル★☆☆入力完了後に実行検索ボックス最適化
12リーディングデバウンスデバウンス・スロットル★★☆最初の呼び出しを即座に実行即時フィードバック
13基本的なスロットルデバウンス・スロットル★☆☆一定間隔で実行スクロールイベント制御
14アダプティブデバウンスデバウンス・スロットル★★★入力速度に応じて調整動的な待機時間調整
15キャンセル可能なデバウンスデバウンス・スロットル★★☆手動でキャンセル可能ユーザー操作による中断
16プログレス表示付きデバウンスデバウンス・スロットル★★☆待機時間を可視化UX 向上
17即時実行オプションデバウンス・スロットル★★☆条件により即座に実行重要な入力の優先処理
18グループ化デバウンスデバウンス・スロットル★★★複数の入力をまとめて処理バッチ処理最適化
19優先度付きスロットルデバウンス・スロットル★★★重要な処理を優先優先順位制御
20メモリ効率的なデバウンスデバウンス・スロットル★★☆メモリリークを防止長期稼働アプリ
21AbortController 基本キャンセル制御★★☆fetch をキャンセルHTTP リクエスト中断
22自動キャンセルキャンセル制御★★☆新しいリクエスト時に前回を中断検索機能での重複防止
23タイムアウトキャンセルキャンセル制御★★☆一定時間後に自動キャンセル長時間処理の制限
24手動キャンセルキャンセル制御★☆☆ユーザー操作でキャンセルキャンセルボタン実装
25コンポーネント連動キャンセルキャンセル制御★★★アンマウント時に自動キャンセルメモリリーク防止
26条件付きキャンセルキャンセル制御★★☆状態変化でキャンセル動的なキャンセル制御
27グループキャンセルキャンセル制御★★★複数のリクエストを一括キャンセル複数処理の一括中断
28優先度付きキャンセルキャンセル制御★★★低優先度の処理をキャンセルリソース最適化
29リソースクリーンアップキャンセル制御★★☆キャンセル時にリソース解放メモリ管理
30キャンセル理由の記録キャンセル制御★★☆デバッグ用にログ記録トラブルシューティング
31重複実行防止同時実行制御★☆☆実行中は再実行を防ぐボタン二度押し防止
32リクエストキュー同時実行制御★★★順次実行するキュー順序保証が必要な処理
33Promise キャッシング同時実行制御★★☆同じリクエストを共有重複リクエスト削減
34並列度制限同時実行制御★★★同時実行数を制限リソース制限
35優先度キュー同時実行制御★★★優先順位に基づいて実行重要度による処理順制御
36レートリミット同時実行制御★★★一定期間の実行回数を制限API 制限対応
37バッチ処理同時実行制御★★★複数のリクエストをまとめるネットワーク効率化
38ストリーミング処理同時実行制御★★★データを分割して処理大量データ処理
39リソースプール同時実行制御★★★リソースを再利用コネクション管理
40デッドロック防止同時実行制御★★★相互待機を回避複雑な依存関係処理
41エラー分類エラーハンドリング★★☆エラーの種類を判別エラータイプ別処理
42グローバルエラーハンドラエラーハンドリング★★☆共通エラー処理一元的なエラー管理
43エラーリカバリーエラーハンドリング★★★自動的に回復自動復旧処理
44ユーザーフレンドリーなメッセージエラーハンドリング★☆☆わかりやすいエラー表示UX 向上
45エラーログ送信エラーハンドリング★★☆サーバーへエラー報告エラー監視
46オフライン対応エラーハンドリング★★★オフライン時の処理オフラインファースト
47部分的エラー処理エラーハンドリング★★★一部成功時の対処バッチ処理のエラー管理
48エラー境界エラーハンドリング★★☆エラーの影響範囲を限定障害の局所化
49デバッグモードエラーハンドリング★★☆開発時の詳細情報表示開発効率向上
50エラーメトリクスエラーハンドリング★★★エラー率の監視パフォーマンス分析

この早見表を参考に、プロジェクトの要件に最適なレシピを選択してください。各レシピの詳細な実装方法は、後続のセクションで解説します。

背景

Pinia は Vue.js の公式状態管理ライブラリとして、Vuex の後継として開発されました。シンプルな API と TypeScript との親和性の高さから、多くのプロジェクトで採用されています。

Pinia のストアは、state(状態)、getters(算出プロパティ)、actions(アクション)の 3 つの要素で構成されます。このうち actions は、状態を変更するロジックや非同期処理を実装する場所として機能します。

以下の図は、Pinia ストアの基本構造を示しています。

mermaidflowchart TB
  component["Vue コンポーネント"]
  store["Pinia ストア"]
  state["state<br/>(状態)"]
  getters["getters<br/>(算出値)"]
  actions["actions<br/>(処理)"]
  api["外部 API"]

  component -->|"dispatch"| actions
  actions -->|"更新"| state
  state -->|"参照"| getters
  getters -->|"reactive"| component
  actions -->|"HTTP 呼び出し"| api
  api -->|"レスポンス"| actions

しかし、実際の開発では、単純な状態更新だけでは不十分なケースが多く存在します。ネットワークエラーへの対処、ユーザー入力の最適化、リソースの効率的な利用など、様々な課題に直面するでしょう。

Pinia アクションの基本形

typescriptimport { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    users: [],
    loading: false,
    error: null,
  }),

  actions: {
    async fetchUsers() {
      this.loading = true;
      try {
        const response = await fetch('/api/users');
        this.users = await response.json();
      } catch (error) {
        this.error = error;
      } finally {
        this.loading = false;
      }
    },
  },
});

上記は最も基本的なアクションの形です。ローディング状態の管理とエラーハンドリングを含んでいますが、実務ではさらに高度な制御が必要になります。

課題

実際の開発現場では、以下のような課題に直面することがあります。

パフォーマンスとユーザー体験の課題

API 呼び出しが頻繁に発生すると、サーバーへの負荷が増大し、レスポンスが遅くなります。例えば、検索ボックスへの入力ごとに API を呼び出すと、1 文字入力するたびにリクエストが送信されてしまいます。

また、ネットワークが不安定な環境では、一時的なエラーでアクションが失敗することがあります。ユーザーに再実行を強いるのではなく、自動的にリトライする仕組みが求められるでしょう。

リソース管理の課題

同じアクションが複数回実行されると、重複したリクエストが発生します。例えば、ボタンの二度押しや、複数のコンポーネントから同時に同じデータを取得しようとする場合です。

さらに、ページ遷移などでコンポーネントがアンマウントされた後も、非同期処理が継続してしまうことがあります。これにより、不要な処理が実行され、メモリリークの原因となります。

以下の図は、適切な制御がない場合の問題を示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Component as コンポーネント
  participant Action as アクション
  participant API as API サーバー

  User ->> Component: 検索入力 "a"
  Component ->> Action: fetchData("a")
  Action ->> API: リクエスト 1

  User ->> Component: 検索入力 "ab"
  Component ->> Action: fetchData("ab")
  Action ->> API: リクエスト 2

  User ->> Component: 検索入力 "abc"
  Component ->> Action: fetchData("abc")
  Action ->> API: リクエスト 3

  Note over API: 3つのリクエストが<br/>並行実行される
  API -->> Action: レスポンス 2
  API -->> Action: レスポンス 3
  API -->> Action: レスポンス 1 (遅延)

  Note over Component: 古いデータで<br/>上書きされる可能性

エラーハンドリングの課題

エラーが発生した際、単にエラーメッセージを表示するだけでは不十分です。エラーの種類に応じて適切な処理が必要になります。

  • ネットワークエラー: 自動リトライ
  • 認証エラー: ログイン画面へリダイレクト
  • バリデーションエラー: ユーザーへフィードバック
  • サーバーエラー: エラーログの送信

これらの課題を解決するために、高度なアクション設計パターンが必要となるのです。

解決策

Pinia アクションの設計パターンを、目的別に分類して 50 個のレシピとしてご紹介します。これらのレシピを組み合わせることで、堅牢で効率的なアプリケーションを構築できるでしょう。

レシピ分類一覧

#カテゴリレシピ数主な用途
1リトライ制御10ネットワークエラーへの対処
2デバウンス・スロットル10パフォーマンス最適化
3キャンセル制御10リソース管理
4同時実行制御10重複リクエスト防止
5エラーハンドリング10堅牢性の向上

カテゴリ 1: リトライ制御(レシピ 1-10)

リトライ制御は、一時的なエラーに対して自動的に再試行する仕組みです。ネットワークの不安定さやサーバーの一時的な過負荷に対処できます。

#レシピ名難易度説明
1基本的なリトライ★☆☆固定回数リトライ
2指数バックオフ★★☆待機時間を徐々に増加
3ジッターありリトライ★★☆ランダムな待機時間を追加
4条件付きリトライ★★☆エラー種別で判断
5タイムアウト付きリトライ★★★制限時間内でリトライ
6段階的リトライ★★☆戦略を段階的に変更
7サーキットブレーカー★★★連続失敗時に停止
8フォールバック付きリトライ★★☆失敗時に代替処理
9リトライ状態の可視化★★☆UI にリトライ情報を表示
10カスタマイズ可能なリトライ★★★設定で動作を変更

カテゴリ 2: デバウンス・スロットル(レシピ 11-20)

ユーザー入力やイベントの頻度を制御し、パフォーマンスを向上させるパターンです。

#レシピ名難易度説明
11基本的なデバウンス★☆☆入力完了後に実行
12リーディングデバウンス★★☆最初の呼び出しを即座に実行
13基本的なスロットル★☆☆一定間隔で実行
14アダプティブデバウンス★★★入力速度に応じて調整
15キャンセル可能なデバウンス★★☆手動でキャンセル可能
16プログレス表示付きデバウンス★★☆待機時間を可視化
17即時実行オプション★★☆条件により即座に実行
18グループ化デバウンス★★★複数の入力をまとめて処理
19優先度付きスロットル★★★重要な処理を優先
20メモリ効率的なデバウンス★★☆メモリリークを防止

カテゴリ 3: キャンセル制御(レシピ 21-30)

実行中のアクションをキャンセルし、不要な処理を停止するパターンです。

#レシピ名難易度説明
21AbortController 基本★★☆fetch をキャンセル
22自動キャンセル★★☆新しいリクエスト時に前回をキャンセル
23タイムアウトキャンセル★★☆一定時間後に自動キャンセル
24手動キャンセル★☆☆ユーザー操作でキャンセル
25コンポーネント連動キャンセル★★★アンマウント時に自動キャンセル
26条件付きキャンセル★★☆状態変化でキャンセル
27グループキャンセル★★★複数のリクエストを一括キャンセル
28優先度付きキャンセル★★★低優先度の処理をキャンセル
29リソースクリーンアップ★★☆キャンセル時にリソース解放
30キャンセル理由の記録★★☆デバッグ用にログ記録

カテゴリ 4: 同時実行制御(レシピ 31-40)

複数の同時実行を制御し、リソースを効率的に利用するパターンです。

#レシピ名難易度説明
31重複実行防止★☆☆実行中は再実行を防ぐ
32リクエストキュー★★★順次実行するキュー
33Promise キャッシング★★☆同じリクエストを共有
34並列度制限★★★同時実行数を制限
35優先度キュー★★★優先順位に基づいて実行
36レートリミット★★★一定期間の実行回数を制限
37バッチ処理★★★複数のリクエストをまとめる
38ストリーミング処理★★★データを分割して処理
39リソースプール★★★リソースを再利用
40デッドロック防止★★★相互待機を回避

カテゴリ 5: エラーハンドリング(レシピ 41-50)

エラーを適切に処理し、ユーザー体験を向上させるパターンです。

#レシピ名難易度説明
41エラー分類★★☆エラーの種類を判別
42グローバルエラーハンドラ★★☆共通エラー処理
43エラーリカバリー★★★自動的に回復
44ユーザーフレンドリーなメッセージ★☆☆わかりやすいエラー表示
45エラーログ送信★★☆サーバーへエラー報告
46オフライン対応★★★オフライン時の処理
47部分的エラー処理★★★一部成功時の対処
48エラー境界★★☆エラーの影響範囲を限定
49デバッグモード★★☆開発時の詳細情報表示
50エラーメトリクス★★★エラー率の監視

これらのレシピを実際のコードで実装する方法を、次のセクションで詳しく見ていきましょう。

具体例

ここからは、主要なレシピの具体的な実装例をご紹介します。コードは機能ごとに分割し、理解しやすくしています。

レシピ 1: 基本的なリトライ

最も基本的なリトライ機能の実装です。指定した回数だけ処理を再試行します。

ユーティリティ関数の作成

まず、汎用的なリトライ関数を作成しましょう。

typescript// utils/retry.ts

/**
 * 指定回数リトライする関数
 * @param fn 実行する関数
 * @param maxRetries 最大リトライ回数
 * @param delay リトライ間隔(ミリ秒)
 */
export async function retry<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3,
  delay: number = 1000
): Promise<T> {
  let lastError: Error;

  for (let i = 0; i <= maxRetries; i++) {
    try {
      // 処理を実行
      return await fn();
    } catch (error) {
      lastError = error as Error;

      // 最後のリトライ以外は待機
      if (i < maxRetries) {
        await new Promise((resolve) =>
          setTimeout(resolve, delay)
        );
      }
    }
  }

  // すべて失敗した場合はエラーをスロー
  throw lastError!;
}

ストアでの利用

作成したリトライ関数を Pinia ストアで使用します。

typescript// stores/user.ts
import { defineStore } from 'pinia';
import { retry } from '@/utils/retry';

export const useUserStore = defineStore('user', {
  state: () => ({
    users: [],
    loading: false,
    error: null,
    retryCount: 0,
  }),

  actions: {
    async fetchUsers() {
      this.loading = true;
      this.error = null;
      this.retryCount = 0;

      try {
        // リトライ機能付きでデータ取得
        const result = await retry(
          async () => {
            this.retryCount++;
            const response = await fetch('/api/users');

            if (!response.ok) {
              throw new Error(
                `HTTP Error: ${response.status}`
              );
            }

            return response.json();
          },
          3, // 最大3回リトライ
          1000 // 1秒待機
        );

        this.users = result;
      } catch (error) {
        this.error = error.message;
      } finally {
        this.loading = false;
      }
    },
  },
});

上記のコードでは、retry 関数を使ってデータ取得処理を最大 3 回リトライします。各リトライの間には 1 秒の待機時間が設けられ、ネットワークの一時的な問題に対処できます。

レシピ 2: 指数バックオフ

リトライ回数が増えるごとに待機時間を長くする、より洗練された方法です。サーバーへの負荷を軽減できます。

typescript// utils/retry.ts

/**
 * 指数バックオフでリトライする関数
 * 待機時間: baseDelay * (2 ^ リトライ回数)
 */
export async function retryWithExponentialBackoff<T>(
  fn: () => Promise<T>,
  maxRetries: number = 5,
  baseDelay: number = 1000
): Promise<T> {
  let lastError: Error;

  for (let i = 0; i <= maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;

      if (i < maxRetries) {
        // 指数的に増加する待機時間を計算
        const waitTime = baseDelay * Math.pow(2, i);
        console.log(
          `リトライ ${
            i + 1
          }/${maxRetries}: ${waitTime}ms 待機`
        );

        await new Promise((resolve) =>
          setTimeout(resolve, waitTime)
        );
      }
    }
  }

  throw lastError!;
}

このパターンでは、1 回目は 1 秒、2 回目は 2 秒、3 回目は 4 秒というように待機時間が増えていきます。サーバーが過負荷の場合に特に有効でしょう。

レシピ 11: 基本的なデバウンス

ユーザーの入力が止まってから処理を実行するパターンです。検索ボックスなどで頻繁に使われます。

デバウンス関数の実装

typescript// utils/debounce.ts

/**
 * デバウンス関数
 * 指定時間内に再度呼ばれた場合、タイマーをリセット
 */
export function debounce<T extends (...args: any[]) => any>(
  fn: T,
  delay: number = 300
): (...args: Parameters<T>) => void {
  let timeoutId: ReturnType<typeof setTimeout> | null =
    null;

  return function (...args: Parameters<T>) {
    // 既存のタイマーをクリア
    if (timeoutId) {
      clearTimeout(timeoutId);
    }

    // 新しいタイマーをセット
    timeoutId = setTimeout(() => {
      fn(...args);
    }, delay);
  };
}

検索機能での利用例

typescript// stores/search.ts
import { defineStore } from 'pinia';
import { debounce } from '@/utils/debounce';

export const useSearchStore = defineStore('search', {
  state: () => ({
    query: '',
    results: [],
    loading: false,
  }),

  actions: {
    // デバウンスされた検索アクション
    debouncedSearch: null as any,

    initDebouncedSearch() {
      // 300ms の待機時間でデバウンス
      this.debouncedSearch = debounce(
        (query: string) => this.performSearch(query),
        300
      );
    },

    async performSearch(query: string) {
      this.loading = true;

      try {
        const response = await fetch(
          `/api/search?q=${query}`
        );
        this.results = await response.json();
      } finally {
        this.loading = false;
      }
    },

    setQuery(query: string) {
      this.query = query;
      // デバウンスされた検索を実行
      this.debouncedSearch(query);
    },
  },
});

コンポーネントからは、setQuery メソッドを呼ぶだけで、自動的にデバウンスされた検索が実行されます。

typescript// components/SearchBox.vue
<script setup lang="ts">
import { useSearchStore } from '@/stores/search'

const searchStore = useSearchStore()
searchStore.initDebouncedSearch()

function handleInput(event: Event) {
  const query = (event.target as HTMLInputElement).value
  // 入力のたびに呼ばれるが、実際の検索は300ms後
  searchStore.setQuery(query)
}
</script>

<template>
  <input
    type="text"
    :value="searchStore.query"
    @input="handleInput"
    placeholder="検索..."
  />
</template>

レシピ 21: AbortController でキャンセル

実行中のリクエストをキャンセルする機能です。ページ遷移時や新しい検索を開始した際に、前回のリクエストを中断できます。

キャンセル可能なアクションの実装

typescript// stores/data.ts
import { defineStore } from 'pinia';

export const useDataStore = defineStore('data', {
  state: () => ({
    data: null,
    loading: false,
    error: null,
    // AbortController を保持
    abortController: null as AbortController | null,
  }),

  actions: {
    async fetchData(params: string) {
      // 前回のリクエストをキャンセル
      if (this.abortController) {
        this.abortController.abort();
      }

      // 新しい AbortController を作成
      this.abortController = new AbortController();
      this.loading = true;
      this.error = null;

      try {
        const response = await fetch(
          `/api/data?params=${params}`,
          {
            // signal を渡してキャンセル可能に
            signal: this.abortController.signal,
          }
        );

        this.data = await response.json();
      } catch (error) {
        // キャンセルによるエラーは無視
        if (error.name === 'AbortError') {
          console.log('リクエストがキャンセルされました');
          return;
        }

        this.error = error.message;
      } finally {
        this.loading = false;
        this.abortController = null;
      }
    },

    // 手動でキャンセルするメソッド
    cancelFetch() {
      if (this.abortController) {
        this.abortController.abort();
        this.abortController = null;
        this.loading = false;
      }
    },
  },
});

この実装により、新しいリクエストが開始されると前回のリクエストが自動的にキャンセルされます。ユーザーが「キャンセル」ボタンを押した場合にも対応できるでしょう。

レシピ 31: 重複実行防止

同じアクションが同時に複数回実行されるのを防ぐパターンです。ボタンの二度押し防止などに使用します。

typescript// stores/form.ts
import { defineStore } from 'pinia';

export const useFormStore = defineStore('form', {
  state: () => ({
    submitting: false,
    result: null,
    error: null,
  }),

  actions: {
    async submitForm(formData: any) {
      // すでに実行中の場合は何もしない
      if (this.submitting) {
        console.warn('フォーム送信が既に実行中です');
        return;
      }

      this.submitting = true;
      this.error = null;

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

        this.result = await response.json();
      } catch (error) {
        this.error = error.message;
      } finally {
        this.submitting = false;
      }
    },
  },
});

ボタン側でも submitting 状態を使って二重送信を防げます。

typescript// components/SubmitButton.vue
<script setup lang="ts">
import { useFormStore } from '@/stores/form'

const formStore = useFormStore()

function handleSubmit() {
  formStore.submitForm({ /* データ */ })
}
</script>

<template>
  <button
    @click="handleSubmit"
    :disabled="formStore.submitting"
  >
    {{ formStore.submitting ? '送信中...' : '送信' }}
  </button>
</template>

レシピ 33: Promise キャッシング

同じリクエストが複数の場所から呼ばれた場合、1 つのリクエストを共有するパターンです。リソースの無駄を削減できます。

Promise キャッシュの実装

typescript// stores/cache.ts
import { defineStore } from 'pinia';

export const useCacheStore = defineStore('cache', {
  state: () => ({
    users: null,
    loading: false,
    // 実行中の Promise を保持
    pendingPromise: null as Promise<any> | null,
  }),

  actions: {
    async fetchUsers() {
      // すでに実行中の Promise がある場合はそれを返す
      if (this.pendingPromise) {
        console.log('既存のリクエストを再利用します');
        return this.pendingPromise;
      }

      this.loading = true;

      // 新しい Promise を作成して保存
      this.pendingPromise = fetch('/api/users')
        .then((response) => response.json())
        .then((data) => {
          this.users = data;
          return data;
        })
        .finally(() => {
          // 完了後はクリア
          this.pendingPromise = null;
          this.loading = false;
        });

      return this.pendingPromise;
    },
  },
});

複数のコンポーネントから同時に fetchUsers を呼んでも、実際の HTTP リクエストは 1 回だけになります。

レシピ 41: エラー分類

エラーの種類を判別し、適切な処理を行うパターンです。

エラー分類ユーティリティ

typescript// utils/error.ts

/**
 * エラーの種類を判別
 */
export enum ErrorType {
  NETWORK = 'network',
  AUTH = 'auth',
  VALIDATION = 'validation',
  SERVER = 'server',
  UNKNOWN = 'unknown',
}

export function classifyError(error: any): ErrorType {
  // ネットワークエラー
  if (error.name === 'NetworkError' || !navigator.onLine) {
    return ErrorType.NETWORK;
  }

  // HTTP ステータスコードで判別
  if (error.response) {
    const status = error.response.status;

    if (status === 401 || status === 403) {
      return ErrorType.AUTH;
    }

    if (status >= 400 && status < 500) {
      return ErrorType.VALIDATION;
    }

    if (status >= 500) {
      return ErrorType.SERVER;
    }
  }

  return ErrorType.UNKNOWN;
}

エラー種別ごとの処理

typescript// stores/api.ts
import { defineStore } from 'pinia';
import { classifyError, ErrorType } from '@/utils/error';

export const useApiStore = defineStore('api', {
  state: () => ({
    data: null,
    error: null,
    errorType: null as ErrorType | null,
  }),

  actions: {
    async fetchData() {
      try {
        const response = await fetch('/api/data');
        this.data = await response.json();
      } catch (error) {
        this.errorType = classifyError(error);

        // エラー種別ごとに処理を分岐
        switch (this.errorType) {
          case ErrorType.NETWORK:
            this.error = 'ネットワークに接続できません';
            // オフライン処理やリトライ
            break;

          case ErrorType.AUTH:
            this.error = '認証が必要です';
            // ログイン画面へリダイレクト
            this.redirectToLogin();
            break;

          case ErrorType.VALIDATION:
            this.error = '入力内容を確認してください';
            // バリデーションエラーの表示
            break;

          case ErrorType.SERVER:
            this.error = 'サーバーエラーが発生しました';
            // エラーログの送信
            this.sendErrorLog(error);
            break;

          default:
            this.error = '予期しないエラーが発生しました';
        }
      }
    },

    redirectToLogin() {
      // ログイン画面への遷移処理
      window.location.href = '/login';
    },

    sendErrorLog(error: any) {
      // エラーログをサーバーに送信
      fetch('/api/log-error', {
        method: 'POST',
        body: JSON.stringify({
          error: error.message,
          stack: error.stack,
          timestamp: new Date().toISOString(),
        }),
      }).catch(() => {
        // ログ送信失敗は無視
      });
    },
  },
});

このように、エラーの種類に応じて適切な処理を実行することで、ユーザー体験が大きく向上します。

複合パターン: リトライ + デバウンス + キャンセル

実際のプロジェクトでは、複数のパターンを組み合わせることが多いでしょう。以下は検索機能の完全な実装例です。

typescript// stores/advancedSearch.ts
import { defineStore } from 'pinia';
import { debounce } from '@/utils/debounce';
import { retryWithExponentialBackoff } from '@/utils/retry';

export const useAdvancedSearchStore = defineStore(
  'advancedSearch',
  {
    state: () => ({
      query: '',
      results: [],
      loading: false,
      error: null,
      abortController: null as AbortController | null,
    }),

    actions: {
      debouncedSearch: null as any,

      initSearch() {
        // デバウンス付き検索(500ms)
        this.debouncedSearch = debounce(
          (query: string) => this.search(query),
          500
        );
      },

      async search(query: string) {
        // 前回のリクエストをキャンセル
        if (this.abortController) {
          this.abortController.abort();
        }

        // 空文字列の場合は検索しない
        if (!query.trim()) {
          this.results = [];
          return;
        }

        this.abortController = new AbortController();
        this.loading = true;
        this.error = null;

        try {
          // リトライ機能付きで検索実行
          const results = await retryWithExponentialBackoff(
            async () => {
              const response = await fetch(
                `/api/search?q=${encodeURIComponent(
                  query
                )}`,
                { signal: this.abortController!.signal }
              );

              if (!response.ok) {
                throw new Error(
                  `検索エラー: ${response.status}`
                );
              }

              return response.json();
            },
            3, // 最大3回リトライ
            1000 // 初期待機時間1秒
          );

          this.results = results;
        } catch (error) {
          if (error.name === 'AbortError') {
            // キャンセルは正常な動作
            return;
          }

          this.error = error.message;
          this.results = [];
        } finally {
          this.loading = false;
          this.abortController = null;
        }
      },

      setQuery(query: string) {
        this.query = query;
        this.debouncedSearch(query);
      },

      clearSearch() {
        this.query = '';
        this.results = [];
        this.error = null;

        if (this.abortController) {
          this.abortController.abort();
        }
      },
    },
  }
);

この実装では、以下の機能が統合されています:

  • デバウンス: ユーザーの入力が止まってから検索
  • キャンセル: 新しい検索開始時に前回のリクエストをキャンセル
  • リトライ: ネットワークエラー時に自動リトライ
  • エラーハンドリング: 適切なエラー処理

以下の図は、これらの機能がどのように連携するかを示しています。

mermaidstateDiagram-v2
  [*] --> Idle: 初期状態
  Idle --> Debouncing: ユーザー入力
  Debouncing --> Debouncing: 追加入力(タイマーリセット)
  Debouncing --> Executing: 待機時間経過
  Executing --> Retrying: エラー発生
  Retrying --> Retrying: リトライ継続
  Retrying --> Success: 成功
  Retrying --> Failed: リトライ上限
  Executing --> Success: 成功
  Success --> Idle: 結果表示
  Failed --> Idle: エラー表示
  Executing --> Cancelled: 新しい入力
  Retrying --> Cancelled: 新しい入力
  Cancelled --> Debouncing: 次の検索へ

この図から、各状態がどのように遷移するかが理解できるでしょう。

まとめ

本記事では、Pinia アクション設計の 50 のレシピをご紹介しました。これらのパターンを活用することで、より堅牢で効率的なアプリケーションを構築できます。

特に重要なポイントをまとめます。

リトライ制御では、ネットワークの一時的な問題に対処できます。指数バックオフを使うことで、サーバーへの負荷も軽減できるでしょう。

デバウンスとスロットルは、ユーザー入力の最適化に不可欠です。検索ボックスやフォーム入力で積極的に活用してください。

キャンセル制御により、不要な処理を停止し、リソースを効率的に利用できます。AbortController は現代の Web 開発における標準的な手法です。

同時実行制御は、重複リクエストを防ぎ、アプリケーションのパフォーマンスを向上させます。Promise キャッシングなどの高度なテクニックも検討してみてください。

エラーハンドリングは、ユーザー体験を大きく左右します。エラーの種類に応じた適切な処理を実装することで、より親切なアプリケーションになるでしょう。

これらのレシピは、単独でも効果的ですが、組み合わせることでさらに強力になります。プロジェクトの要件に応じて、最適なパターンを選択してください。

実装する際は、まず基本的なパターンから始め、必要に応じて高度な機能を追加していくことをお勧めします。コードの可読性と保守性を保ちながら、段階的に改善していきましょう。

関連リンク