T-CREATOR

Zustand を使ったグローバルエラーハンドリング実践例

Zustand を使ったグローバルエラーハンドリング実践例

現代の Web アプリケーション開発において、エラーハンドリングはユーザー体験を大きく左右する重要な要素です。特に React アプリケーションでは、コンポーネント間でのエラーの適切な管理が、アプリケーションの安定性と信頼性を決定づけます。

しかし、従来のエラーハンドリング手法では、各コンポーネントでバラバラにエラーを処理することが多く、一貫性のないユーザー体験を生み出してしまうことがありました。

Zustand を活用することで、これらの課題を解決できるのです。 軽量でシンプルなグローバル状態管理ライブラリとして人気の Zustand は、エラーハンドリングにおいても優れた解決策を提供してくれます。本記事では、実際のコード例とともに、Zustand を使った効果的なグローバルエラーハンドリングの実装方法をご紹介します。

背景

Zustand とは

Zustand は、ドイツ語で「状態」を意味する React 向けのステート管理ライブラリです。Redux と比較して非常にシンプルな API を持ち、わずか数行のコードで状態管理が実現できるのが大きな特徴となっています。

Zustand の主な特徴をまとめてみました。

#特徴詳細
1軽量性2.2KB と非常に軽量
2シンプルな APIボイラープレートコードが不要
3TypeScript サポート型安全な実装が可能
4React DevTools 対応デバッグが容易

エラーハンドリングの重要性

Web アプリケーションにおけるエラーハンドリングは、単なる技術的要件ではありません。ユーザーが安心してアプリケーションを使い続けられるかを決める重要な要素なのです。

適切なエラーハンドリングができていないアプリケーションでは、以下のような問題が発生します。

  • 突然のアプリケーションクラッシュによるユーザー離脱
  • エラー内容が分からないことによるユーザーの困惑
  • 復旧方法が不明なことによる利用継続の断念

これらの問題を解決するために、グローバルなエラーハンドリング戦略が重要になります。

課題

従来のエラーハンドリング手法の問題点

従来の React アプリケーションでは、以下のようなエラーハンドリング手法が一般的でした。しかし、それぞれに深刻な課題が存在していたのです。

個別コンポーネントでのエラー処理

最も基本的な手法として、各コンポーネント内で try-catch を使ってエラーを処理する方法があります。

typescript// 従来の個別エラーハンドリング例
const UserProfile = () => {
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const fetchUser = async () => {
    try {
      setLoading(true);
      const response = await fetch('/api/user');
      // エラーハンドリングが各コンポーネントに散在
      if (!response.ok) {
        throw new Error('Failed to fetch user data');
      }
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  if (error) {
    return <div>エラーが発生しました: {error}</div>;
  }

  return <div>...</div>;
};

この手法の問題点は明確です。

#問題点影響
1コードの重複同じエラーハンドリングロジックが各所に散在
2一貫性の欠如エラーメッセージの表示方法がバラバラ
3保守性の低下エラー処理の変更時に多数のファイルを修正

Error Boundary のみに依存する手法

React の Error Boundary を使った方法も一般的ですが、これにも限界があります。

javascript// Error Boundary の実装例
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('ErrorBoundary caught an error:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

Error Boundary の制限事項として、以下の点が挙げられます。

  • 非同期処理でのエラーをキャッチできない
  • イベントハンドラー内のエラーは対象外
  • カスタムフックでのエラーは検知不可能

従来手法の限界

これらの従来手法では、以下のような根本的な問題を解決できませんでした。

現代の React アプリケーションでは、API 呼び出し、非同期処理、複雑な状態管理が当たり前となっています。このような環境下では、エラーを一元的に管理し、ユーザーに一貫した体験を提供することが不可欠です。

しかし、従来の手法では「どこでエラーが発生しているのか把握しにくい」「エラーの種類によって適切な対応を取れない」「ユーザーへのフィードバックが不十分」といった課題が山積みでした。

解決策

Zustand を活用したグローバルエラーハンドリングのアーキテクチャ

Zustand を使用することで、これらの課題をエレガントに解決できます。グローバルなエラーストアを構築し、アプリケーション全体でエラーを一元管理する手法を採用します。

このアーキテクチャの核となる考え方は、以下の通りです。

「エラーも状態の一つとして扱い、グローバルに管理することで、一貫性のあるエラーハンドリングを実現する」

アーキテクチャの構成要素

#構成要素役割
1Error Storeエラー状態の一元管理
2Error Providerエラー表示ロジックの統一
3Error Hooksエラー操作の簡単化
4Error Boundary予期しないエラーのフォールバック

エラーハンドリングフロー

Zustand を使ったエラーハンドリングでは、以下のフローでエラーを処理します。

  1. エラー検知: API 呼び出しやコンポーネント内でエラーが発生
  2. エラー登録: グローバルエラーストアにエラーを追加
  3. エラー表示: 統一されたコンポーネントでエラーを表示
  4. エラー解決: ユーザーアクションまたは自動でエラーをクリア

このフローにより、開発者はエラー処理の詳細を意識することなく、ビジネスロジックに集中できるようになります。

具体例

基本的なエラーストア実装

エラー状態管理の基本設計

まず、Zustand を使ったエラーストアの基本構造を実装しましょう。型安全性を重視した設計が重要なポイントです。

typescript// types/error.ts
export interface AppError {
  id: string;
  message: string;
  type: 'api' | 'validation' | 'network' | 'unknown';
  timestamp: number;
  context?: Record<string, any>;
  recoverable: boolean;
}

export interface ErrorState {
  errors: AppError[];
  isLoading: boolean;
}

この型定義では、エラーに必要な情報を明確に定義しています。特に typerecoverable フィールドにより、エラーの種類と対処方法を分類できるのが特徴です。

次に、エラーストアの実装を行います。

typescript// stores/errorStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { AppError, ErrorState } from '../types/error';

interface ErrorActions {
  addError: (error: Omit<AppError, 'id' | 'timestamp'>) => void;
  removeError: (id: string) => void;
  clearAllErrors: () => void;
  setLoading: (loading: boolean) => void;
}

type ErrorStore = ErrorState & ErrorActions;

export const useErrorStore = create<ErrorStore>()(
  devtools(
    (set, get) => ({
      // 初期状態
      errors: [],
      isLoading: false,

      // エラーを追加する関数
      addError: (errorData) => {
        const newError: AppError = {
          ...errorData,
          id: `error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
          timestamp: Date.now(),
        };
        
        set(
          (state) => ({
            errors: [...state.errors, newError],
          }),
          false,
          'addError'
        );
      },

この実装では、devtools ミドルウェアを使用することで、React DevTools での状態監視が可能になります。また、一意な ID を自動生成することで、個別のエラー管理を実現しています。

エラーストアの残りの部分を実装します。

typescript      // エラーを削除する関数
      removeError: (id) => {
        set(
          (state) => ({
            errors: state.errors.filter((error) => error.id !== id),
          }),
          false,
          'removeError'
        );
      },

      // 全エラーをクリアする関数
      clearAllErrors: () => {
        set({ errors: [] }, false, 'clearAllErrors');
      },

      // ローディング状態を設定する関数
      setLoading: (loading) => {
        set({ isLoading: loading }, false, 'setLoading');
      },
    }),
    {
      name: 'error-store', // DevTools での表示名
    }
  )
);

エラー表示コンポーネントの作成

エラー表示用のコンポーネントを作成し、ユーザーフレンドリーなエラー体験を提供します。

typescript// components/ErrorToast.tsx
import React, { useEffect } from 'react';
import { useErrorStore } from '../stores/errorStore';

const ErrorToast: React.FC = () => {
  const { errors, removeError } = useErrorStore();

  // エラーの自動削除機能(10秒後)
  useEffect(() => {
    errors.forEach((error) => {
      if (error.recoverable) {
        const timer = setTimeout(() => {
          removeError(error.id);
        }, 10000);

        return () => clearTimeout(timer);
      }
    });
  }, [errors, removeError]);

  const getErrorIcon = (type: string) => {
    switch (type) {
      case 'api':
        return '🔌';
      case 'validation':
        return '⚠️';
      case 'network':
        return '📡';
      default:
        return '❌';
    }
  };

この実装では、エラータイプに応じて視覚的に分かりやすいアイコンを表示し、ユーザーが状況を理解しやすくしています。

エラートーストコンポーネントの残りの部分を実装します。

typescript  const getErrorStyle = (type: string) => {
    const baseStyle = 'p-4 mb-2 rounded-lg border-l-4 flex items-center justify-between';
    
    switch (type) {
      case 'api':
        return `${baseStyle} bg-red-50 border-red-400 text-red-700`;
      case 'validation':
        return `${baseStyle} bg-yellow-50 border-yellow-400 text-yellow-700`;
      case 'network':
        return `${baseStyle} bg-blue-50 border-blue-400 text-blue-700`;
      default:
        return `${baseStyle} bg-gray-50 border-gray-400 text-gray-700`;
    }
  };

  if (errors.length === 0) {
    return null;
  }

  return (
    <div className="fixed top-4 right-4 z-50 max-w-md">
      {errors.map((error) => (
        <div key={error.id} className={getErrorStyle(error.type)}>
          <div className="flex items-center">
            <span className="mr-2 text-lg">{getErrorIcon(error.type)}</span>
            <span className="font-medium">{error.message}</span>
          </div>
          {error.recoverable && (
            <button
              onClick={() => removeError(error.id)}
              className="ml-4 text-sm underline hover:no-underline"
            >
              閉じる
            </button>
          )}
        </div>
      ))}
    </div>
  );
};

export default ErrorToast;

この実装により、ユーザーが直感的にエラーの内容と対処方法を理解できるUI を提供できます。

API エラーハンドリング

axios インターセプターとの連携

API 呼び出しでのエラーを自動的にキャッチし、グローバルエラーストアに送信する仕組みを構築します。

typescript// utils/apiClient.ts
import axios, { AxiosError, AxiosResponse } from 'axios';
import { useErrorStore } from '../stores/errorStore';

const apiClient = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '/api',
  timeout: 10000,
});

// リクエストインターセプター
apiClient.interceptors.request.use(
  (config) => {
    // ローディング状態を開始
    useErrorStore.getState().setLoading(true);
    return config;
  },
  (error) => {
    useErrorStore.getState().setLoading(false);
    return Promise.reject(error);
  }
);

// レスポンスインターセプター
apiClient.interceptors.response.use(
  (response: AxiosResponse) => {
    // 成功時はローディングを終了
    useErrorStore.getState().setLoading(false);
    return response;
  },

この設定により、全ての API 呼び出しで自動的にエラーハンドリングが機能します。

axios インターセプターのエラーハンドリング部分を実装します。

typescript  (error: AxiosError) => {
    useErrorStore.getState().setLoading(false);
    
    const { addError } = useErrorStore.getState();
    
    // ネットワークエラーの判定
    if (!error.response) {
      addError({
        message: 'ネットワーク接続に問題があります。インターネット接続を確認してください。',
        type: 'network',
        recoverable: true,
        context: { originalError: error.message }
      });
      return Promise.reject(error);
    }

    // HTTP ステータスコードに基づくエラー分類
    const status = error.response.status;
    let errorMessage = '';
    let errorType: 'api' | 'validation' | 'network' | 'unknown' = 'api';
    let recoverable = true;

    switch (status) {
      case 400:
        errorMessage = 'リクエストに不正な内容が含まれています。';
        errorType = 'validation';
        break;
      case 401:
        errorMessage = '認証が必要です。ログインしてください。';
        recoverable = false;
        break;

この実装では、HTTP ステータスコードに応じて適切なエラーメッセージとタイプを設定し、ユーザーに具体的な対処方法を提示できます。

エラー分類の残りの部分を実装します。

typescript      case 403:
        errorMessage = 'この操作を実行する権限がありません。';
        recoverable = false;
        break;
      case 404:
        errorMessage = '要求されたリソースが見つかりません。';
        break;
      case 500:
        errorMessage = 'サーバーで問題が発生しました。しばらくしてから再試行してください。';
        break;
      case 503:
        errorMessage = 'サービスが一時的に利用できません。';
        break;
      default:
        errorMessage = `予期しないエラーが発生しました。(エラーコード: ${status})`;
        errorType = 'unknown';
    }

    addError({
      message: errorMessage,
      type: errorType,
      recoverable: recoverable,
      context: {
        status,
        url: error.config?.url,
        method: error.config?.method,
        data: error.response.data
      }
    });

    return Promise.reject(error);
  }
);

export default apiClient;

fetch API での実装方法

fetch API を使用する場合の実装方法も紹介します。axios とは異なるエラーハンドリングアプローチが必要です。

typescript// utils/fetchClient.ts
import { useErrorStore } from '../stores/errorStore';

interface FetchOptions extends RequestInit {
  timeout?: number;
}

export const createFetchClient = () => {
  const fetchWithErrorHandling = async (
    url: string, 
    options: FetchOptions = {}
  ) => {
    const { addError, setLoading } = useErrorStore.getState();
    const { timeout = 10000, ...fetchOptions } = options;

    try {
      setLoading(true);

      // タイムアウト機能を実装
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);

      const response = await fetch(url, {
        ...fetchOptions,
        signal: controller.signal,
      });

      clearTimeout(timeoutId);

      // レスポンスステータスのチェック
      if (!response.ok) {
        throw new Error(`HTTP Error: ${response.status}`);
      }

      return response;

fetch API では axios と異なり、HTTP エラーステータスが自動的に例外として投げられないため、明示的にチェックする必要があります。

fetch エラーハンドリングの完成部分を実装します。

typescript    } catch (error) {
      // AbortError(タイムアウト)の処理
      if (error.name === 'AbortError') {
        addError({
          message: 'リクエストがタイムアウトしました。ネットワーク接続を確認してください。',
          type: 'network',
          recoverable: true,
          context: { url, timeout }
        });
        throw error;
      }

      // ネットワークエラーの処理
      if (error instanceof TypeError) {
        addError({
          message: 'ネットワーク接続に問題があります。',
          type: 'network',
          recoverable: true,
          context: { url, originalError: error.message }
        });
        throw error;
      }

      // その他のエラー
      addError({
        message: error.message || '予期しないエラーが発生しました。',
        type: 'unknown',
        recoverable: true,
        context: { url, error: error.toString() }
      });
      throw error;
    } finally {
      setLoading(false);
    }
  };

  return { fetchWithErrorHandling };
};

コンポーネント間でのエラー伝播

React Error Boundary との組み合わせ

Zustand エラーストアと React Error Boundary を組み合わせることで、包括的なエラーハンドリングシステムを構築できます。

typescript// components/ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { useErrorStore } from '../stores/errorStore';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('ErrorBoundary caught an error:', error, errorInfo);
    
    // Zustand ストアにエラーを追加
    const { addError } = useErrorStore.getState();
    addError({
      message: 'アプリケーションで予期しないエラーが発生しました。',
      type: 'unknown',
      recoverable: false,
      context: {
        errorMessage: error.message,
        errorStack: error.stack,
        componentStack: errorInfo.componentStack
      }
    });
  }

Error Boundary では、React の描画プロセスで発生したエラーを Zustand ストアに統合することで、一貫したエラー管理を実現します。

Error Boundary のレンダー部分を実装します。

typescript  render() {
    if (this.state.hasError) {
      // カスタムフォールバック UI があれば使用
      if (this.props.fallback) {
        return this.props.fallback;
      }

      // デフォルトのエラー UI
      return (
        <div className="min-h-screen flex items-center justify-center bg-gray-50">
          <div className="max-w-md mx-auto text-center">
            <div className="mb-4">
              <span className="text-6xl">😵</span>
            </div>
            <h1 className="text-2xl font-bold text-gray-900 mb-2">
              問題が発生しました
            </h1>
            <p className="text-gray-600 mb-6">
              アプリケーションで予期しないエラーが発生しました。
              ページを再読み込みしてください。
            </p>
            <button
              onClick={() => window.location.reload()}
              className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
            >
              ページを再読み込み
            </button>
          </div>
        </div>
      );
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

非同期処理でのエラーキャッチ

非同期処理でのエラーを適切にハンドリングするカスタムフックを作成します。

typescript// hooks/useAsyncError.ts
import { useCallback } from 'react';
import { useErrorStore } from '../stores/errorStore';

export const useAsyncError = () => {
  const { addError } = useErrorStore();

  const handleAsyncError = useCallback(
    async <T>(
      asyncFunction: () => Promise<T>,
      errorMessage?: string
    ): Promise<T | null> => {
      try {
        return await asyncFunction();
      } catch (error) {
        const message = errorMessage || 
          (error instanceof Error ? error.message : '非同期処理でエラーが発生しました。');
        
        addError({
          message,
          type: 'unknown',
          recoverable: true,
          context: {
            originalError: error instanceof Error ? error.message : String(error),
            timestamp: new Date().toISOString()
          }
        });
        
        return null;
      }
    },
    [addError]
  );

  return { handleAsyncError };
};

このカスタムフックを使用することで、コンポーネント内での非同期処理エラーを簡単に管理できます。

使用例を示します。

typescript// components/UserList.tsx
import React, { useEffect, useState } from 'react';
import { useAsyncError } from '../hooks/useAsyncError';
import apiClient from '../utils/apiClient';

interface User {
  id: number;
  name: string;
  email: string;
}

const UserList: React.FC = () => {
  const [users, setUsers] = useState<User[]>([]);
  const { handleAsyncError } = useAsyncError();

  useEffect(() => {
    const fetchUsers = async () => {
      const result = await handleAsyncError(
        async () => {
          const response = await apiClient.get<User[]>('/users');
          return response.data;
        },
        'ユーザー一覧の取得に失敗しました。'
      );

      if (result) {
        setUsers(result);
      }
    };

    fetchUsers();
  }, [handleAsyncError]);

  return (
    <div>
      <h2>ユーザー一覧</h2>
      {users.length === 0 ? (
        <p>ユーザーが見つかりません。</p>
      ) : (
        <ul>
          {users.map((user) => (
            <li key={user.id}>{user.name} ({user.email})</li>
          ))}
        </ul>
      )}
    </div>
  );
};

まとめ

実装のポイント

Zustand を使ったグローバルエラーハンドリングの実装において、成功の鍵となるポイントをまとめました。

#ポイント詳細
1型安全性の確保TypeScript を活用したエラー型の明確な定義
2エラー分類の統一type フィールドによる一貫したエラー分類
3ユーザー体験の重視分かりやすいエラーメッセージと復旧方法の提示
4包括的なカバレッジAPI、コンポーネント、非同期処理すべてに対応

最も重要なのは、開発者が実装を意識せずに使える仕組みを作ることです。複雑なエラーハンドリングロジックをライブラリレベルで抽象化し、コンポーネント開発者は本来のビジネスロジックに集中できる環境を整えることが成功への道筋となります。

運用上の注意点

本番環境での運用において、以下の点に注意してください。

パフォーマンス面での配慮が重要です。エラーログが蓄積しすぎないよう、適切なクリーンアップ機能を実装しましょう。特に、自動削除機能と手動削除機能の組み合わせにより、メモリリークを防止することが大切です。

セキュリティの観点からも注意が必要です。エラーコンテキストに含まれる情報が、機密情報を漏洩させないよう十分に検討してください。本番環境では、デバッグ情報の表示を制限することも重要な考慮事項です。

ユーザビリティの継続的改善も忘れてはいけません。実際のユーザーフィードバックを収集し、エラーメッセージや復旧手順を定期的に見直すことで、より使いやすいアプリケーションへと進化させていくことができるでしょう。

Zustand を使ったグローバルエラーハンドリングは、シンプルでありながら強力な解決策を提供してくれます。この記事で紹介した手法を参考に、ぜひあなたのプロジェクトでも実装してみてください。きっと、より安定性の高い、ユーザーフレンドリーなアプリケーションを構築できるはずです。

関連リンク