T-CREATOR

状態遷移を明文化する:XState × Jotai の堅牢な非同期フロー設計

状態遷移を明文化する:XState × Jotai の堅牢な非同期フロー設計

現代の React アプリケーション開発において、状態管理の複雑さは開発者を悩ませる大きな課題となっています。特に非同期処理や複雑な状態遷移を含むアプリケーションでは、予期しないバグや保守性の低下といった問題が発生しがちです。

そこで今回は、XState と Jotai を組み合わせた革新的な状態管理アプローチをご紹介いたします。両者の強みを活かし、明確で予測可能な状態遷移を実現する設計パターンについて詳しく解説していきましょう。

背景

React における状態管理の課題

React アプリケーションの規模が大きくなるにつれて、状態管理は複雑化の一途を辿ります。従来のuseStateuseReducerを用いた手法では、以下のような課題に直面することが多くありました。

最も大きな問題は、状態の変更が予期しない副作用を引き起こすことです。コンポーネント間での状態共有や、非同期処理による状態の競合状態など、開発者が意図しない動作が発生しやすくなります。また、状態の変更履歴を追跡することが困難で、デバッグ時に原因の特定に時間がかかってしまいます。

さらに、複雑なビジネスロジックを含む状態遷移では、どの状態からどの状態へ遷移できるのかが曖昧になりがちです。これにより、不正な状態遷移が発生し、アプリケーションの信頼性が損なわれる可能性があります。

XState と Jotai それぞれの特徴と限界

XState の特徴

XState は有限状態機械(Finite State Machine)とステートチャート(Statechart)の概念に基づいた状態管理ライブラリです。状態の遷移を明文化し、どの状態からどの状態へ変更できるかを厳密に定義できるのが最大の特徴です。

typescript// XStateマシンの基本構造例
import { createMachine } from 'xstate';

const authMachine = createMachine({
  id: 'authentication',
  initial: 'idle',
  states: {
    idle: {
      on: {
        LOGIN_START: 'authenticating',
      },
    },
    authenticating: {
      on: {
        LOGIN_SUCCESS: 'authenticated',
        LOGIN_FAILURE: 'error',
      },
    },
  },
});

XState の強みは、状態遷移の可視化とテスタビリティの高さにあります。状態図として表現できるため、開発チーム間でのコミュニケーションも改善されます。

しかし、XState 単体では細かなデータ管理において冗長な記述が必要になることがあります。また、学習コストが比較的高く、シンプルな状態管理には過剰な場合もありました。

Jotai の特徴

一方、Jotai はアトミック(原子)な状態管理を提供するライブラリです。小さな状態の単位(アトム)を組み合わせて、複雑な状態を構築できます。

typescript// Jotaiアトムの基本例
import { atom } from 'jotai';

const userAtom = atom({
  id: null,
  name: '',
  email: '',
});

const isLoggedInAtom = atom((get) => {
  const user = get(userAtom);
  return user.id !== null;
});

Jotai の優れている点は、必要な部分だけを再レンダリングする細粒度なリアクティブシステムです。パフォーマンスに優れ、記述もシンプルで直感的です。

ただし、複雑な状態遷移のロジックを表現する際には、どのような順序で状態が変更されるべきかが不明確になりがちでした。

なぜ両者を組み合わせる必要があるのか

XState と Jotai の組み合わせは、それぞれの弱点を補完し合う理想的な構成です。XState で状態遷移の流れを明確に定義し、Jotai で実際のデータ管理を行うという役割分担により、以下のメリットが得られます。

状態遷移のフロー制御は XState が担当し、データの詳細な管理は Jotai が行うことで、コードの可読性と保守性が大幅に向上します。また、テスタビリティも高まり、品質の高いアプリケーション開発が可能になるのです。

次の図は、両者の統合アーキテクチャの概念を示しています。

mermaidflowchart TB
    subgraph XState["XState ステートマシン"]
        idle[待機状態]
        loading[読み込み中]
        success[成功]
        error[エラー]
    end

    subgraph Jotai["Jotai アトム"]
        dataAtom[データアトム]
        errorAtom[エラーアトム]
        configAtom[設定アトム]
    end

    idle -->|フェッチ開始| loading
    loading -->|成功| success
    loading -->|失敗| error
    success -->|リセット| idle
    error -->|再試行| loading

    loading -.->|状態更新| dataAtom
    error -.->|エラー設定| errorAtom
    success -.->|データ格納| dataAtom

この図のように、XState が状態の遷移フローを管理し、各状態で Jotai のアトムにデータを格納する構造により、明確で予測可能な状態管理が実現できます。

課題

従来の State 管理パターンの問題点

React 開発における従来の状態管理手法には、いくつかの根本的な課題が存在していました。最も顕著な問題は、状態の変更が暗黙的に行われることです。

useStateuseReducerを用いた一般的なパターンでは、どのタイミングでどのような状態変更が発生するかが、コード全体を読まなければ理解できません。特に複数のコンポーネントにわたって状態を共有する場合、想定外の状態変更が発生するリスクが高くなります。

typescript// 従来の問題のあるパターン例
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);

// 複数の場所から状態を変更する可能性
const fetchData = async () => {
  setIsLoading(true);
  setError(null); // この操作が漏れる可能性
  try {
    const result = await api.fetchData();
    setData(result);
  } catch (err) {
    setError(err.message);
  } finally {
    setIsLoading(false); // エラー時にも確実に実行される保証がない
  }
};

このような実装では、状態の整合性を保つことが困難で、バグの温床となりがちです。

複雑な非同期フローの制御の困難さ

現代の Web アプリケーションでは、API 通信、WebSocket 接続、タイマー処理など、多様な非同期処理を扱う必要があります。これらの処理が組み合わさった際の状態制御は、従来手法では非常に複雑になります。

例えば、ユーザー認証とデータフェッチが並行して実行される場合を考えてみましょう。認証が完了する前にデータフェッチが開始されたり、逆に認証エラー時にデータフェッチが継続されたりと、タイミングの制御が困難になります。

typescript// 複雑な非同期フローの問題例
useEffect(() => {
  // 認証チェック
  checkAuth().then((isAuthenticated) => {
    if (isAuthenticated) {
      // データフェッチが複数箇所で開始される可能性
      fetchUserData();
      fetchSettings();
    }
  });

  // WebSocket接続も同時に開始
  const ws = connectWebSocket();

  return () => {
    ws.close(); // クリーンアップが複雑になる
  };
}, []);

このように、複数の非同期処理が絡み合うと、競合状態やメモリリークが発生しやすくなります。

状態遷移の可視化と管理の課題

開発チームでの協業において、現在のアプリケーションがどのような状態遷移を持つかを共有することは極めて重要です。しかし、従来の手法では状態遷移が暗黙的で、ドキュメント化が困難でした。

新しいメンバーがプロジェクトに参加した際、状態の流れを理解するためにコード全体を読み解く必要があり、学習コストが高くなってしまいます。また、仕様変更時に影響範囲を把握することも困難で、予期しない副作用が発生するリスクが高まります。

以下の図は、従来の状態管理における課題を整理したものです。

mermaidflowchart TD
    implicit[暗黙的な状態変更]
    async[複雑な非同期処理]
    visibility[可視化の困難さ]

    implicit --> bugs[予期しないバグ]
    async --> race[競合状態]
    visibility --> maintenance[保守性の低下]

    bugs --> quality[品質問題]
    race --> quality
    maintenance --> quality

    quality --> cost[開発コストの増大]

これらの課題を解決するために、XState と Jotai の組み合わせによる新しいアプローチが求められているのです。

解決策

XState × Jotai アーキテクチャの設計思想

XState と Jotai を組み合わせたアーキテクチャの核心は、責任の分離にあります。状態の遷移ロジックと実際のデータ管理を明確に分けることで、それぞれの特性を最大限に活かせるのです。

この設計思想において、XState は「いつ」「どのように」状態が変化するかを定義し、Jotai は「何を」格納するかに専念します。これにより、複雑なアプリケーションロジックを整理し、予測可能で保守性の高いコードを実現できます。

アーキテクチャの基本構造を以下の図で示します。

mermaidflowchart LR
    subgraph UI["UIコンポーネント"]
        comp1[ログインフォーム]
        comp2[ダッシュボード]
        comp3[設定画面]
    end

    subgraph XState["XState層"]
        machine1[認証マシン]
        machine2[データマシン]
        machine3[設定マシン]
    end

    subgraph Jotai["Jotai層"]
        atom1[ユーザーアトム]
        atom2[データアトム]
        atom3[設定アトム]
    end

    comp1 -->|イベント送信| machine1
    comp2 -->|イベント送信| machine2
    comp3 -->|イベント送信| machine3

    machine1 -->|状態更新| atom1
    machine2 -->|状態更新| atom2
    machine3 -->|状態更新| atom3

    atom1 -->|状態購読| comp1
    atom2 -->|状態購読| comp2
    atom3 -->|状態購読| comp3

この構造により、UI コンポーネントは XState マシンにイベントを送信し、マシンは適切な状態遷移を行った後、Jotai アトムにデータを格納します。

状態機械とアトミック状態の役割分担

XState の役割:状態遷移の制御

XState は有限状態機械として、アプリケーションが取りうる状態と、それらの状態間の遷移を厳密に定義します。これにより、不正な状態遷移を防ぎ、予測可能な動作を保証できます。

typescript// 認証フローのXStateマシン
import { createMachine, assign } from 'xstate';

const authMachine = createMachine({
  id: 'auth',
  initial: 'idle',
  context: {
    retryCount: 0,
    maxRetries: 3,
  },
  states: {
    idle: {
      on: {
        LOGIN_START: 'authenticating',
      },
    },
    authenticating: {
      on: {
        LOGIN_SUCCESS: {
          target: 'authenticated',
          actions: 'resetRetryCount',
        },
        LOGIN_FAILURE: {
          target: 'error',
          actions: 'incrementRetry',
        },
      },
    },
    authenticated: {
      on: {
        LOGOUT: 'idle',
        SESSION_EXPIRED: 'idle',
      },
    },
    error: {
      on: {
        RETRY: {
          target: 'authenticating',
          guard: 'canRetry',
        },
        RESET: 'idle',
      },
    },
  },
});

Jotai の役割:データの管理と配信

Jotai は実際のデータを格納し、コンポーネント間での状態共有を効率的に行います。アトミックな設計により、必要な部分のみが再レンダリングされ、パフォーマンスの最適化も図れます。

typescript// ユーザー情報を管理するJotaiアトム
import { atom } from 'jotai';

// 基本的なユーザーデータ
export const userAtom = atom({
  id: null as string | null,
  name: '',
  email: '',
  role: 'guest' as 'admin' | 'user' | 'guest',
});

// 認証状態を派生するアトム
export const isAuthenticatedAtom = atom((get) => {
  const user = get(userAtom);
  return user.id !== null;
});

// 権限チェック用の派生アトム
export const hasAdminRoleAtom = atom((get) => {
  const user = get(userAtom);
  return user.role === 'admin';
});

非同期処理とエラーハンドリングの統合

XState と Jotai を組み合わせることで、複雑な非同期処理とエラーハンドリングを統合的に管理できます。XState のマシンで非同期フローを制御し、結果を Jotai アトムに格納することで、一貫性のある状態管理を実現します。

typescript// 非同期処理を含むXStateマシン
const dataFetchMachine = createMachine({
  id: 'dataFetch',
  initial: 'idle',
  states: {
    idle: {
      on: {
        FETCH: 'loading',
      },
    },
    loading: {
      invoke: {
        id: 'fetchData',
        src: async (context, event) => {
          const response = await fetch('/api/data');
          if (!response.ok) {
            throw new Error('データの取得に失敗しました');
          }
          return response.json();
        },
        onDone: {
          target: 'success',
          actions: 'updateDataAtom',
        },
        onError: {
          target: 'error',
          actions: 'updateErrorAtom',
        },
      },
    },
    success: {
      on: {
        REFETCH: 'loading',
        RESET: 'idle',
      },
    },
    error: {
      on: {
        RETRY: 'loading',
        RESET: 'idle',
      },
    },
  },
});

このアプローチにより、エラー状態も明確に管理され、ユーザーに適切なフィードバックを提供できるようになります。

エラーハンドリングの統合フローを以下の図で表現します。

mermaidsequenceDiagram
    participant UI as UIコンポーネント
    participant XS as XStateマシン
    participant API as API
    participant JO as Jotaiアトム

    UI->>XS: FETCH イベント送信
    XS->>XS: loading 状態に遷移
    XS->>API: データリクエスト

    alt 成功の場合
        API->>XS: データ返却
        XS->>JO: データをアトムに格納
        XS->>XS: success 状態に遷移
        JO->>UI: 新しいデータで再レンダリング
    else エラーの場合
        API->>XS: エラー返却
        XS->>JO: エラー情報をアトムに格納
        XS->>XS: error 状態に遷移
        JO->>UI: エラー表示で再レンダリング
    end

この統合されたアプローチにより、非同期処理の各段階で適切な状態管理が行われ、ユーザー体験の向上に繋がります。

具体例

ログイン機能での実装パターン

ログイン機能は、XState と Jotai の組み合わせを理解するのに最適な例です。認証フローという明確な状態遷移を持ちながら、ユーザー情報という具体的なデータ管理も必要になります。

まず、認証フローを管理する XState マシンを定義しましょう。

typescript// authMachine.ts
import { createMachine, assign } from 'xstate';
import { authService } from '../services/authService';

interface AuthContext {
  email: string;
  error: string | null;
  retryCount: number;
}

type AuthEvent =
  | { type: 'LOGIN_START'; email: string; password: string }
  | { type: 'LOGIN_SUCCESS'; user: any }
  | { type: 'LOGIN_FAILURE'; error: string }
  | { type: 'LOGOUT' }
  | { type: 'RETRY' };

export const authMachine = createMachine<
  AuthContext,
  AuthEvent
>({
  id: 'auth',
  initial: 'idle',
  context: {
    email: '',
    error: null,
    retryCount: 0,
  },
  states: {
    idle: {
      on: {
        LOGIN_START: {
          target: 'authenticating',
          actions: assign({
            email: (_, event) => event.email,
            error: null,
          }),
        },
      },
    },
    authenticating: {
      invoke: {
        id: 'authenticate',
        src: async (context, event) => {
          if (event.type !== 'LOGIN_START') return;
          return await authService.login(
            event.email,
            event.password
          );
        },
        onDone: {
          target: 'authenticated',
          actions: ['updateUserAtom', 'resetRetryCount'],
        },
        onError: {
          target: 'error',
          actions: assign({
            error: (_, event) => event.data.message,
            retryCount: (context) => context.retryCount + 1,
          }),
        },
      },
    },
    authenticated: {
      on: {
        LOGOUT: {
          target: 'idle',
          actions: 'clearUserAtom',
        },
      },
    },
    error: {
      on: {
        RETRY: {
          target: 'authenticating',
          guard: (context) => context.retryCount < 3,
        },
        LOGIN_START: {
          target: 'authenticating',
          actions: assign({
            retryCount: 0,
            error: null,
          }),
        },
      },
    },
  },
});

次に、ユーザー情報を管理する Jotai アトムを作成します。

typescript// atoms/userAtoms.ts
import { atom } from 'jotai';

export interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
  avatar?: string;
}

// ユーザー情報のベースアトム
export const userAtom = atom<User | null>(null);

// 認証状態を派生するアトム
export const isAuthenticatedAtom = atom((get) => {
  const user = get(userAtom);
  return user !== null;
});

// ユーザーの権限レベルを判定するアトム
export const userPermissionsAtom = atom((get) => {
  const user = get(userAtom);
  if (!user) return { canEdit: false, canAdmin: false };

  return {
    canEdit: true,
    canAdmin: user.role === 'admin',
  };
});

XState マシンと Jotai アトムを連携させるフックを作成します。

typescript// hooks/useAuth.ts
import { useMachine } from '@xstate/react';
import { useAtom } from 'jotai';
import { authMachine } from '../machines/authMachine';
import { userAtom } from '../atoms/userAtoms';

export const useAuth = () => {
  const [state, send] = useMachine(authMachine, {
    actions: {
      updateUserAtom: (context, event) => {
        if (event.type === 'done.invoke.authenticate') {
          setUser(event.data.user);
        }
      },
      clearUserAtom: () => {
        setUser(null);
      },
      resetRetryCount: assign({
        retryCount: 0,
      }),
    },
  });

  const [user, setUser] = useAtom(userAtom);

  const login = (email: string, password: string) => {
    send({ type: 'LOGIN_START', email, password });
  };

  const logout = () => {
    send({ type: 'LOGOUT' });
  };

  const retry = () => {
    send({ type: 'RETRY' });
  };

  return {
    // 状態
    isIdle: state.matches('idle'),
    isLoading: state.matches('authenticating'),
    isAuthenticated: state.matches('authenticated'),
    isError: state.matches('error'),

    // データ
    user,
    error: state.context.error,
    canRetry: state.context.retryCount < 3,

    // アクション
    login,
    logout,
    retry,
  };
};

最後に、ログインコンポーネントでこれらを使用します。

typescript// components/LoginForm.tsx
import React, { useState } from 'react';
import { useAuth } from '../hooks/useAuth';

export const LoginForm: React.FC = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const {
    login,
    isLoading,
    isError,
    error,
    canRetry,
    retry,
  } = useAuth();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    login(email, password);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type='email'
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder='メールアドレス'
        disabled={isLoading}
      />

      <input
        type='password'
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder='パスワード'
        disabled={isLoading}
      />

      <button type='submit' disabled={isLoading}>
        {isLoading ? 'ログイン中...' : 'ログイン'}
      </button>

      {isError && (
        <div className='error'>
          <p>{error}</p>
          {canRetry && (
            <button onClick={retry}>再試行</button>
          )}
        </div>
      )}
    </form>
  );
};

この実装により、ログイン機能における状態遷移が明確になり、エラーハンドリングも含めた堅牢な認証システムを構築できます。

API 通信を伴うデータフェッチング

データフェッチングは、非同期処理の典型的な例であり、XState と Jotai の連携効果を最も実感できる場面です。キャッシュ機能やエラー処理、リトライ機能を含む包括的なデータ管理システムを実装してみましょう。

まず、データフェッチング用の XState マシンを作成します。

typescript// machines/dataFetchMachine.ts
import { createMachine, assign } from 'xstate';

interface DataFetchContext {
  endpoint: string;
  params: Record<string, any>;
  retryCount: number;
  lastFetchTime: number | null;
}

export const dataFetchMachine =
  createMachine<DataFetchContext>({
    id: 'dataFetch',
    initial: 'idle',
    context: {
      endpoint: '',
      params: {},
      retryCount: 0,
      lastFetchTime: null,
    },
    states: {
      idle: {
        on: {
          FETCH: {
            target: 'checking_cache',
            actions: assign({
              endpoint: (_, event) => event.endpoint,
              params: (_, event) => event.params || {},
              retryCount: 0,
            }),
          },
        },
      },
      checking_cache: {
        always: [
          {
            target: 'serving_cache',
            guard: 'isCacheValid',
          },
          {
            target: 'fetching',
          },
        ],
      },
      serving_cache: {
        entry: 'serveCachedData',
        on: {
          REFETCH: 'fetching',
          INVALIDATE: 'fetching',
        },
      },
      fetching: {
        invoke: {
          id: 'fetchData',
          src: async (context) => {
            const response = await fetch(
              `${context.endpoint}?${new URLSearchParams(
                context.params
              )}`
            );
            if (!response.ok) {
              throw new Error(
                `HTTP ${response.status}: ${response.statusText}`
              );
            }
            return response.json();
          },
          onDone: {
            target: 'success',
            actions: [
              'updateDataAtom',
              'updateLastFetchTime',
            ],
          },
          onError: {
            target: 'error',
            actions: [
              'updateErrorAtom',
              'incrementRetryCount',
            ],
          },
        },
      },
      success: {
        on: {
          FETCH: {
            target: 'checking_cache',
            actions: assign({
              endpoint: (_, event) => event.endpoint,
              params: (_, event) => event.params || {},
            }),
          },
          REFETCH: 'fetching',
        },
      },
      error: {
        on: {
          RETRY: {
            target: 'fetching',
            guard: (context) => context.retryCount < 3,
          },
          RESET: 'idle',
        },
      },
    },
  });

データとキャッシュを管理する Jotai アトムを作成します。

typescript// atoms/dataAtoms.ts
import { atom } from 'jotai';

// データキャッシュの型定義
interface CachedData<T = any> {
  data: T;
  timestamp: number;
  key: string;
}

// キャッシュストアのアトム
export const cacheAtom = atom<Map<string, CachedData>>(
  new Map()
);

// 現在のデータを保持するアトム
export const currentDataAtom = atom<any>(null);

// ローディング状態のアトム
export const loadingAtom = atom<boolean>(false);

// エラー状態のアトム
export const errorAtom = atom<string | null>(null);

// キャッシュから特定のデータを取得する派生アトム
export const getCachedDataAtom = atom(
  null,
  (get, set, key: string) => {
    const cache = get(cacheAtom);
    return cache.get(key);
  }
);

// キャッシュにデータを保存する派生アトム
export const setCachedDataAtom = atom(
  null,
  (get, set, key: string, data: any) => {
    const cache = new Map(get(cacheAtom));
    cache.set(key, {
      data,
      timestamp: Date.now(),
      key,
    });
    set(cacheAtom, cache);
  }
);

データフェッチングを管理するカスタムフックを作成します。

typescript// hooks/useDataFetch.ts
import { useMachine } from '@xstate/react';
import { useAtom } from 'jotai';
import { dataFetchMachine } from '../machines/dataFetchMachine';
import {
  currentDataAtom,
  loadingAtom,
  errorAtom,
  getCachedDataAtom,
  setCachedDataAtom,
} from '../atoms/dataAtoms';

const CACHE_DURATION = 5 * 60 * 1000; // 5分

export const useDataFetch = () => {
  const [state, send] = useMachine(dataFetchMachine, {
    guards: {
      isCacheValid: (context) => {
        const cacheKey = `${
          context.endpoint
        }_${JSON.stringify(context.params)}`;
        const cached = getCachedData(cacheKey);

        if (!cached) return false;

        const isExpired =
          Date.now() - cached.timestamp > CACHE_DURATION;
        return !isExpired;
      },
    },
    actions: {
      serveCachedData: (context) => {
        const cacheKey = `${
          context.endpoint
        }_${JSON.stringify(context.params)}`;
        const cached = getCachedData(cacheKey);
        if (cached) {
          setCurrentData(cached.data);
        }
      },
      updateDataAtom: (context, event) => {
        if (event.type === 'done.invoke.fetchData') {
          const cacheKey = `${
            context.endpoint
          }_${JSON.stringify(context.params)}`;
          setCachedData(cacheKey, event.data);
          setCurrentData(event.data);
          setError(null);
        }
      },
      updateErrorAtom: (context, event) => {
        if (event.type === 'error.platform.fetchData') {
          setError(event.data.message);
        }
      },
      updateLastFetchTime: assign({
        lastFetchTime: () => Date.now(),
      }),
      incrementRetryCount: assign({
        retryCount: (context) => context.retryCount + 1,
      }),
    },
  });

  const [currentData, setCurrentData] =
    useAtom(currentDataAtom);
  const [loading, setLoading] = useAtom(loadingAtom);
  const [error, setError] = useAtom(errorAtom);
  const [, getCachedData] = useAtom(getCachedDataAtom);
  const [, setCachedData] = useAtom(setCachedDataAtom);

  // ローディング状態の同期
  React.useEffect(() => {
    setLoading(state.matches('fetching'));
  }, [state.value, setLoading]);

  const fetchData = (
    endpoint: string,
    params?: Record<string, any>
  ) => {
    send({ type: 'FETCH', endpoint, params });
  };

  const refetch = () => {
    send({ type: 'REFETCH' });
  };

  const retry = () => {
    send({ type: 'RETRY' });
  };

  const reset = () => {
    send({ type: 'RESET' });
    setCurrentData(null);
    setError(null);
  };

  return {
    // 状態
    isIdle: state.matches('idle'),
    isLoading: state.matches('fetching'),
    isServingCache: state.matches('serving_cache'),
    isSuccess: state.matches('success'),
    isError: state.matches('error'),

    // データ
    data: currentData,
    error,
    canRetry: state.context.retryCount < 3,

    // アクション
    fetchData,
    refetch,
    retry,
    reset,
  };
};

このデータフェッチングシステムを使用するコンポーネントの例です。

typescript// components/UserList.tsx
import React, { useEffect } from 'react';
import { useDataFetch } from '../hooks/useDataFetch';

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

export const UserList: React.FC = () => {
  const {
    data,
    isLoading,
    isError,
    error,
    canRetry,
    fetchData,
    refetch,
    retry,
  } = useDataFetch();

  useEffect(() => {
    fetchData('/api/users', { page: 1, limit: 10 });
  }, []);

  if (isLoading) {
    return (
      <div className='loading'>
        ユーザーデータを読み込み中...
      </div>
    );
  }

  if (isError) {
    return (
      <div className='error'>
        <p>エラーが発生しました: {error}</p>
        {canRetry && (
          <button onClick={retry}>再試行</button>
        )}
      </div>
    );
  }

  const users: User[] = data?.users || [];

  return (
    <div className='user-list'>
      <div className='header'>
        <h2>ユーザー一覧</h2>
        <button onClick={refetch}>最新データを取得</button>
      </div>

      <ul>
        {users.map((user) => (
          <li key={user.id} className='user-item'>
            <span className='name'>{user.name}</span>
            <span className='email'>{user.email}</span>
          </li>
        ))}
      </ul>
    </div>
  );
};

このフロー図は、データフェッチング機能の全体的な動作を示しています。

mermaidsequenceDiagram
    participant UI as UIコンポーネント
    participant Hook as useDataFetch
    participant XS as XStateマシン
    participant Cache as キャッシュアトム
    participant API as API

    UI->>Hook: fetchData('/api/users')
    Hook->>XS: FETCH イベント
    XS->>XS: checking_cache 状態
    XS->>Cache: キャッシュ確認

    alt キャッシュが有効
        Cache->>XS: キャッシュデータ返却
        XS->>XS: serving_cache 状態
        XS->>UI: キャッシュデータ表示
    else キャッシュが無効またはなし
        XS->>XS: fetching 状態
        XS->>API: データリクエスト
        API->>XS: データ返却
        XS->>Cache: データをキャッシュ
        XS->>UI: 新しいデータ表示
    end

この実装により、効率的なキャッシュ機能を持つデータフェッチングシステムが構築され、ユーザー体験の向上とサーバー負荷の軽減を同時に実現できます。

マルチステップフォームの状態管理

マルチステップフォームは、複数の画面にわたってユーザーの入力を収集し、最終的に一つのデータセットとして処理する機能です。ここでは、ユーザー登録フローを例に、XState と Jotai を使った実装をご紹介します。

まず、フォームの進行状態を管理する XState マシンを作成します。

typescript// machines/registrationMachine.ts
import { createMachine, assign } from 'xstate';

interface RegistrationContext {
  currentStep: number;
  totalSteps: number;
  errors: Record<string, string[]>;
  isSubmitting: boolean;
}

type RegistrationEvent =
  | { type: 'NEXT_STEP' }
  | { type: 'PREV_STEP' }
  | { type: 'GOTO_STEP'; step: number }
  | { type: 'VALIDATE_STEP'; step: number; data: any }
  | { type: 'SUBMIT_FORM' }
  | { type: 'RESET_FORM' };

export const registrationMachine = createMachine<
  RegistrationContext,
  RegistrationEvent
>({
  id: 'registration',
  initial: 'step1',
  context: {
    currentStep: 1,
    totalSteps: 4,
    errors: {},
    isSubmitting: false,
  },
  states: {
    step1: {
      meta: { stepNumber: 1 },
      on: {
        NEXT_STEP: {
          target: 'step2',
          guard: 'isStep1Valid',
        },
        VALIDATE_STEP: {
          actions: 'validateStep1',
        },
      },
    },
    step2: {
      meta: { stepNumber: 2 },
      on: {
        NEXT_STEP: {
          target: 'step3',
          guard: 'isStep2Valid',
        },
        PREV_STEP: 'step1',
        VALIDATE_STEP: {
          actions: 'validateStep2',
        },
      },
    },
    step3: {
      meta: { stepNumber: 3 },
      on: {
        NEXT_STEP: {
          target: 'step4',
          guard: 'isStep3Valid',
        },
        PREV_STEP: 'step2',
        VALIDATE_STEP: {
          actions: 'validateStep3',
        },
      },
    },
    step4: {
      meta: { stepNumber: 4 },
      on: {
        PREV_STEP: 'step3',
        SUBMIT_FORM: {
          target: 'submitting',
        },
        VALIDATE_STEP: {
          actions: 'validateStep4',
        },
      },
    },
    submitting: {
      entry: assign({ isSubmitting: true }),
      invoke: {
        id: 'submitRegistration',
        src: 'submitRegistrationData',
        onDone: 'success',
        onError: {
          target: 'step4',
          actions: 'handleSubmitError',
        },
      },
    },
    success: {
      type: 'final',
      entry: 'handleSuccess',
    },
  },
  on: {
    RESET_FORM: {
      target: 'step1',
      actions: 'resetFormData',
    },
  },
});

フォームデータを管理する Jotai アトムを作成します。

typescript// atoms/registrationAtoms.ts
import { atom } from 'jotai';

// Step 1: 基本情報
export const basicInfoAtom = atom({
  firstName: '',
  lastName: '',
  email: '',
  phone: '',
});

// Step 2: アカウント情報
export const accountInfoAtom = atom({
  username: '',
  password: '',
  confirmPassword: '',
});

// Step 3: プロフィール情報
export const profileInfoAtom = atom({
  bio: '',
  website: '',
  company: '',
  jobTitle: '',
});

// Step 4: 設定
export const settingsAtom = atom({
  newsletter: false,
  notifications: true,
  privacy: 'public' as 'public' | 'private',
});

// バリデーションエラー
export const validationErrorsAtom = atom<
  Record<string, string[]>
>({});

// 全フォームデータを統合する派生アトム
export const completeFormDataAtom = atom((get) => ({
  ...get(basicInfoAtom),
  ...get(accountInfoAtom),
  ...get(profileInfoAtom),
  ...get(settingsAtom),
}));

// フォームの完了度を計算する派生アトム
export const formProgressAtom = atom((get) => {
  const basic = get(basicInfoAtom);
  const account = get(accountInfoAtom);
  const profile = get(profileInfoAtom);
  const settings = get(settingsAtom);

  const requiredFields = [
    basic.firstName,
    basic.lastName,
    basic.email,
    account.username,
    account.password,
  ];

  const filledFields = requiredFields.filter(
    (field) => field.trim() !== ''
  ).length;
  return Math.round(
    (filledFields / requiredFields.length) * 100
  );
});

バリデーション関数を定義します。

typescript// utils/validation.ts
export const validateBasicInfo = (data: any) => {
  const errors: string[] = [];

  if (!data.firstName.trim()) {
    errors.push('名前(姓)は必須です');
  }

  if (!data.lastName.trim()) {
    errors.push('名前(名)は必須です');
  }

  if (!data.email.trim()) {
    errors.push('メールアドレスは必須です');
  } else if (!/\S+@\S+\.\S+/.test(data.email)) {
    errors.push('有効なメールアドレスを入力してください');
  }

  return errors;
};

export const validateAccountInfo = (data: any) => {
  const errors: string[] = [];

  if (!data.username.trim()) {
    errors.push('ユーザー名は必須です');
  } else if (data.username.length < 3) {
    errors.push('ユーザー名は3文字以上で入力してください');
  }

  if (!data.password) {
    errors.push('パスワードは必須です');
  } else if (data.password.length < 8) {
    errors.push('パスワードは8文字以上で入力してください');
  }

  if (data.password !== data.confirmPassword) {
    errors.push('パスワードが一致しません');
  }

  return errors;
};

マルチステップフォームのフックを作成します。

typescript// hooks/useRegistrationForm.ts
import { useMachine } from '@xstate/react';
import { useAtom } from 'jotai';
import { registrationMachine } from '../machines/registrationMachine';
import {
  basicInfoAtom,
  accountInfoAtom,
  profileInfoAtom,
  settingsAtom,
  validationErrorsAtom,
  completeFormDataAtom,
  formProgressAtom,
} from '../atoms/registrationAtoms';
import {
  validateBasicInfo,
  validateAccountInfo,
} from '../utils/validation';

export const useRegistrationForm = () => {
  const [state, send] = useMachine(registrationMachine, {
    guards: {
      isStep1Valid: () => {
        const errors = validateBasicInfo(basicInfo);
        return errors.length === 0;
      },
      isStep2Valid: () => {
        const errors = validateAccountInfo(accountInfo);
        return errors.length === 0;
      },
      isStep3Valid: () => true, // プロフィールは任意項目
      isStep4Valid: () => true, // 設定は任意項目
    },
    actions: {
      validateStep1: () => {
        const errors = validateBasicInfo(basicInfo);
        setValidationErrors((prev) => ({
          ...prev,
          step1: errors,
        }));
      },
      validateStep2: () => {
        const errors = validateAccountInfo(accountInfo);
        setValidationErrors((prev) => ({
          ...prev,
          step2: errors,
        }));
      },
      handleSubmitError: (_, event) => {
        console.error('Registration failed:', event.data);
      },
      handleSuccess: () => {
        console.log('Registration successful');
      },
      resetFormData: () => {
        setBasicInfo({
          firstName: '',
          lastName: '',
          email: '',
          phone: '',
        });
        setAccountInfo({
          username: '',
          password: '',
          confirmPassword: '',
        });
        setProfileInfo({
          bio: '',
          website: '',
          company: '',
          jobTitle: '',
        });
        setSettings({
          newsletter: false,
          notifications: true,
          privacy: 'public',
        });
        setValidationErrors({});
      },
    },
    services: {
      submitRegistrationData: async () => {
        const formData = completeFormData;
        const response = await fetch('/api/register', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(formData),
        });

        if (!response.ok) {
          throw new Error('Registration failed');
        }

        return response.json();
      },
    },
  });

  const [basicInfo, setBasicInfo] = useAtom(basicInfoAtom);
  const [accountInfo, setAccountInfo] =
    useAtom(accountInfoAtom);
  const [profileInfo, setProfileInfo] =
    useAtom(profileInfoAtom);
  const [settings, setSettings] = useAtom(settingsAtom);
  const [validationErrors, setValidationErrors] = useAtom(
    validationErrorsAtom
  );
  const [completeFormData] = useAtom(completeFormDataAtom);
  const [progress] = useAtom(formProgressAtom);

  const getCurrentStep = () => {
    const stepMap: Record<string, number> = {
      step1: 1,
      step2: 2,
      step3: 3,
      step4: 4,
    };
    return stepMap[state.value as string] || 1;
  };

  return {
    // 現在の状態
    currentStep: getCurrentStep(),
    totalSteps: 4,
    isSubmitting: state.matches('submitting'),
    isComplete: state.matches('success'),
    progress,

    // フォームデータ
    basicInfo,
    accountInfo,
    profileInfo,
    settings,

    // エラー
    errors: validationErrors,

    // アクション
    setBasicInfo,
    setAccountInfo,
    setProfileInfo,
    setSettings,

    // ナビゲーション
    nextStep: () => send({ type: 'NEXT_STEP' }),
    prevStep: () => send({ type: 'PREV_STEP' }),
    goToStep: (step: number) =>
      send({ type: 'GOTO_STEP', step }),

    // フォーム操作
    validateCurrentStep: () => {
      const step = getCurrentStep();
      send({
        type: 'VALIDATE_STEP',
        step,
        data: completeFormData,
      });
    },
    submitForm: () => send({ type: 'SUBMIT_FORM' }),
    resetForm: () => send({ type: 'RESET_FORM' }),
  };
};

各ステップのコンポーネントを作成します。

typescript// components/RegistrationSteps/Step1BasicInfo.tsx
import React from 'react';
import { useRegistrationForm } from '../../hooks/useRegistrationForm';

export const Step1BasicInfo: React.FC = () => {
  const {
    basicInfo,
    setBasicInfo,
    errors,
    nextStep,
    validateCurrentStep,
  } = useRegistrationForm();

  const handleNext = () => {
    validateCurrentStep();
    if (!errors.step1?.length) {
      nextStep();
    }
  };

  return (
    <div className='form-step'>
      <h2>基本情報</h2>

      <div className='form-group'>
        <label htmlFor='firstName'>名前(姓)*</label>
        <input
          id='firstName'
          type='text'
          value={basicInfo.firstName}
          onChange={(e) =>
            setBasicInfo((prev) => ({
              ...prev,
              firstName: e.target.value,
            }))
          }
          className={
            errors.step1?.some((e) =>
              e.includes('名前(姓)')
            )
              ? 'error'
              : ''
          }
        />
      </div>

      <div className='form-group'>
        <label htmlFor='lastName'>名前(名)*</label>
        <input
          id='lastName'
          type='text'
          value={basicInfo.lastName}
          onChange={(e) =>
            setBasicInfo((prev) => ({
              ...prev,
              lastName: e.target.value,
            }))
          }
          className={
            errors.step1?.some((e) =>
              e.includes('名前(名)')
            )
              ? 'error'
              : ''
          }
        />
      </div>

      <div className='form-group'>
        <label htmlFor='email'>メールアドレス*</label>
        <input
          id='email'
          type='email'
          value={basicInfo.email}
          onChange={(e) =>
            setBasicInfo((prev) => ({
              ...prev,
              email: e.target.value,
            }))
          }
          className={
            errors.step1?.some((e) => e.includes('メール'))
              ? 'error'
              : ''
          }
        />
      </div>

      <div className='form-group'>
        <label htmlFor='phone'>電話番号</label>
        <input
          id='phone'
          type='tel'
          value={basicInfo.phone}
          onChange={(e) =>
            setBasicInfo((prev) => ({
              ...prev,
              phone: e.target.value,
            }))
          }
        />
      </div>

      {errors.step1 && (
        <div className='error-list'>
          {errors.step1.map((error, index) => (
            <p key={index} className='error-message'>
              {error}
            </p>
          ))}
        </div>
      )}

      <div className='form-actions'>
        <button
          type='button'
          onClick={handleNext}
          className='btn-primary'
        >
          次へ
        </button>
      </div>
    </div>
  );
};

プログレスバーコンポーネントも作成します。

typescript// components/RegistrationProgress.tsx
import React from 'react';
import { useRegistrationForm } from '../hooks/useRegistrationForm';

export const RegistrationProgress: React.FC = () => {
  const { currentStep, totalSteps, progress } =
    useRegistrationForm();

  const stepTitles = [
    '基本情報',
    'アカウント',
    'プロフィール',
    '設定',
  ];

  return (
    <div className='registration-progress'>
      <div className='progress-bar'>
        <div
          className='progress-fill'
          style={{
            width: `${(currentStep / totalSteps) * 100}%`,
          }}
        />
      </div>

      <div className='steps'>
        {stepTitles.map((title, index) => (
          <div
            key={index}
            className={`step ${
              index + 1 <= currentStep ? 'active' : ''
            } ${
              index + 1 === currentStep ? 'current' : ''
            }`}
          >
            <span className='step-number'>{index + 1}</span>
            <span className='step-title'>{title}</span>
          </div>
        ))}
      </div>

      <div className='completion-rate'>
        完了度: {progress}%
      </div>
    </div>
  );
};

マルチステップフォームの状態遷移を以下の図で表現します。

mermaidstateDiagram-v2
    [*] --> Step1: フォーム開始

    Step1 --> Step2: バリデーション成功
    Step1 --> Step1: バリデーション失敗

    Step2 --> Step3: バリデーション成功
    Step2 --> Step1: 戻る
    Step2 --> Step2: バリデーション失敗

    Step3 --> Step4: 次へ
    Step3 --> Step2: 戻る

    Step4 --> Submitting: フォーム送信
    Step4 --> Step3: 戻る

    Submitting --> Success: 送信成功
    Submitting --> Step4: 送信失敗

    Success --> [*]: 完了

    note right of Step1: 基本情報入力
    note right of Step2: アカウント設定
    note right of Step3: プロフィール設定
    note right of Step4: 最終確認
    note right of Submitting: サーバー送信中

この実装により、複雑なマルチステップフォームでも、明確な状態管理と直感的なユーザー体験を提供できます。

リアルタイム通信の状態制御

WebSocket を使用したリアルタイム通信は、接続状態や メッセージの送受信、エラーハンドリングなど、複雑な状態管理が必要です。ここでは、チャットアプリケーションを例に、XState と Jotai を使った実装をご紹介します。

まず、WebSocket 接続の状態を管理する XState マシンを作成します。

typescript// machines/websocketMachine.ts
import { createMachine, assign } from 'xstate';

interface WebSocketContext {
  url: string;
  connection: WebSocket | null;
  reconnectAttempts: number;
  maxReconnectAttempts: number;
  reconnectDelay: number;
  lastError: string | null;
}

type WebSocketEvent =
  | { type: 'CONNECT'; url: string }
  | { type: 'DISCONNECT' }
  | { type: 'CONNECTION_OPENED' }
  | {
      type: 'CONNECTION_CLOSED';
      code: number;
      reason: string;
    }
  | { type: 'CONNECTION_ERROR'; error: Event }
  | { type: 'MESSAGE_RECEIVED'; data: any }
  | { type: 'SEND_MESSAGE'; message: any }
  | { type: 'RECONNECT' };

export const websocketMachine = createMachine<
  WebSocketContext,
  WebSocketEvent
>({
  id: 'websocket',
  initial: 'disconnected',
  context: {
    url: '',
    connection: null,
    reconnectAttempts: 0,
    maxReconnectAttempts: 5,
    reconnectDelay: 1000,
    lastError: null,
  },
  states: {
    disconnected: {
      on: {
        CONNECT: {
          target: 'connecting',
          actions: assign({
            url: (_, event) => event.url,
            reconnectAttempts: 0,
            lastError: null,
          }),
        },
      },
    },
    connecting: {
      invoke: {
        id: 'websocket-connection',
        src: (context) => (callback) => {
          const ws = new WebSocket(context.url);

          ws.onopen = () => {
            callback({ type: 'CONNECTION_OPENED' });
          };

          ws.onclose = (event) => {
            callback({
              type: 'CONNECTION_CLOSED',
              code: event.code,
              reason: event.reason,
            });
          };

          ws.onerror = (error) => {
            callback({ type: 'CONNECTION_ERROR', error });
          };

          ws.onmessage = (event) => {
            try {
              const data = JSON.parse(event.data);
              callback({ type: 'MESSAGE_RECEIVED', data });
            } catch (error) {
              console.error(
                'Failed to parse message:',
                error
              );
            }
          };

          return ws;
        },
        onDone: {
          target: 'connected',
          actions: assign({
            connection: (_, event) => event.data,
          }),
        },
      },
      on: {
        CONNECTION_OPENED: 'connected',
        CONNECTION_CLOSED: {
          target: 'disconnected',
          actions: 'handleConnectionClosed',
        },
        CONNECTION_ERROR: {
          target: 'error',
          actions: assign({
            lastError: (_, event) => event.error.toString(),
          }),
        },
        DISCONNECT: 'disconnecting',
      },
    },
    connected: {
      on: {
        MESSAGE_RECEIVED: {
          actions: 'handleMessageReceived',
        },
        SEND_MESSAGE: {
          actions: 'sendMessage',
        },
        CONNECTION_CLOSED: {
          target: 'reconnecting',
          actions: 'handleConnectionClosed',
        },
        CONNECTION_ERROR: {
          target: 'error',
          actions: assign({
            lastError: (_, event) => event.error.toString(),
          }),
        },
        DISCONNECT: 'disconnecting',
      },
    },
    disconnecting: {
      entry: 'closeConnection',
      always: 'disconnected',
    },
    reconnecting: {
      entry: assign({
        reconnectAttempts: (context) =>
          context.reconnectAttempts + 1,
      }),
      after: {
        RECONNECT_DELAY: [
          {
            target: 'connecting',
            guard: 'canReconnect',
          },
          {
            target: 'error',
          },
        ],
      },
      on: {
        DISCONNECT: 'disconnecting',
      },
    },
    error: {
      on: {
        RECONNECT: {
          target: 'connecting',
          actions: assign({
            reconnectAttempts: 0,
          }),
        },
        CONNECT: {
          target: 'connecting',
          actions: assign({
            url: (_, event) => event.url,
            reconnectAttempts: 0,
            lastError: null,
          }),
        },
      },
    },
  },
});

チャットメッセージを管理する Jotai アトムを作成します。

typescript// atoms/chatAtoms.ts
import { atom } from 'jotai';

// メッセージの型定義
export interface ChatMessage {
  id: string;
  userId: string;
  username: string;
  content: string;
  timestamp: Date;
  type: 'text' | 'system' | 'error';
}

// ユーザー情報の型定義
export interface ChatUser {
  id: string;
  username: string;
  isOnline: boolean;
  avatar?: string;
}

// チャットメッセージの配列を管理するアトム
export const messagesAtom = atom<ChatMessage[]>([]);

// オンラインユーザーの配列を管理するアトム
export const onlineUsersAtom = atom<ChatUser[]>([]);

// 現在のユーザー情報を管理するアトム
export const currentUserAtom = atom<ChatUser | null>(null);

// 接続状態を管理するアトム
export const connectionStatusAtom = atom<
  'disconnected' | 'connecting' | 'connected' | 'error'
>('disconnected');

// 未読メッセージ数を計算する派生アトム
export const unreadCountAtom = atom((get) => {
  const messages = get(messagesAtom);
  // 簡単な例として、最後に確認したタイムスタンプ以降のメッセージ数をカウント
  return messages.filter((msg) => msg.type === 'text')
    .length;
});

// メッセージを追加するアクションアトム
export const addMessageAtom = atom(
  null,
  (get, set, newMessage: ChatMessage) => {
    const messages = get(messagesAtom);
    set(messagesAtom, [...messages, newMessage]);
  }
);

// システムメッセージを追加するアクションアトム
export const addSystemMessageAtom = atom(
  null,
  (get, set, content: string) => {
    const systemMessage: ChatMessage = {
      id: Date.now().toString(),
      userId: 'system',
      username: 'システム',
      content,
      timestamp: new Date(),
      type: 'system',
    };
    const messages = get(messagesAtom);
    set(messagesAtom, [...messages, systemMessage]);
  }
);

WebSocket とチャット機能を統合するフックを作成します。

typescript// hooks/useWebSocketChat.ts
import { useMachine } from '@xstate/react';
import { useAtom } from 'jotai';
import { websocketMachine } from '../machines/websocketMachine';
import {
  messagesAtom,
  onlineUsersAtom,
  currentUserAtom,
  connectionStatusAtom,
  addMessageAtom,
  addSystemMessageAtom,
} from '../atoms/chatAtoms';

export const useWebSocketChat = () => {
  const [state, send] = useMachine(websocketMachine, {
    guards: {
      canReconnect: (context) => {
        return (
          context.reconnectAttempts <
          context.maxReconnectAttempts
        );
      },
    },
    actions: {
      handleMessageReceived: (context, event) => {
        if (event.type === 'MESSAGE_RECEIVED') {
          const { data } = event;

          switch (data.type) {
            case 'chat_message':
              addMessage({
                id: data.id,
                userId: data.userId,
                username: data.username,
                content: data.content,
                timestamp: new Date(data.timestamp),
                type: 'text',
              });
              break;

            case 'user_joined':
              addSystemMessage(
                `${data.username}さんがチャットに参加しました`
              );
              updateOnlineUsers(data.onlineUsers);
              break;

            case 'user_left':
              addSystemMessage(
                `${data.username}さんがチャットを退出しました`
              );
              updateOnlineUsers(data.onlineUsers);
              break;

            case 'online_users':
              updateOnlineUsers(data.users);
              break;

            default:
              console.log(
                'Unknown message type:',
                data.type
              );
          }
        }
      },

      sendMessage: (context, event) => {
        if (
          event.type === 'SEND_MESSAGE' &&
          context.connection
        ) {
          const message = JSON.stringify(event.message);
          context.connection.send(message);
        }
      },

      handleConnectionClosed: () => {
        setConnectionStatus('disconnected');
        addSystemMessage('接続が切断されました');
      },

      closeConnection: (context) => {
        if (context.connection) {
          context.connection.close();
        }
      },
    },
    delays: {
      RECONNECT_DELAY: (context) =>
        context.reconnectDelay *
        Math.pow(2, context.reconnectAttempts),
    },
  });

  const [messages] = useAtom(messagesAtom);
  const [onlineUsers, setOnlineUsers] =
    useAtom(onlineUsersAtom);
  const [currentUser, setCurrentUser] =
    useAtom(currentUserAtom);
  const [connectionStatus, setConnectionStatus] = useAtom(
    connectionStatusAtom
  );
  const [, addMessage] = useAtom(addMessageAtom);
  const [, addSystemMessage] = useAtom(
    addSystemMessageAtom
  );

  // 接続状態をアトムに同期
  React.useEffect(() => {
    if (state.matches('connecting')) {
      setConnectionStatus('connecting');
    } else if (state.matches('connected')) {
      setConnectionStatus('connected');
    } else if (
      state.matches('error') ||
      state.matches('reconnecting')
    ) {
      setConnectionStatus('error');
    } else {
      setConnectionStatus('disconnected');
    }
  }, [state.value, setConnectionStatus]);

  const updateOnlineUsers = (users: ChatUser[]) => {
    setOnlineUsers(users);
  };

  const connect = (url: string, user: ChatUser) => {
    setCurrentUser(user);
    send({ type: 'CONNECT', url });
  };

  const disconnect = () => {
    send({ type: 'DISCONNECT' });
  };

  const sendChatMessage = (content: string) => {
    if (!currentUser || !state.matches('connected')) return;

    const message = {
      type: 'chat_message',
      content,
      userId: currentUser.id,
      username: currentUser.username,
      timestamp: new Date().toISOString(),
    };

    send({ type: 'SEND_MESSAGE', message });
  };

  const reconnect = () => {
    send({ type: 'RECONNECT' });
  };

  return {
    // 状態
    isConnected: state.matches('connected'),
    isConnecting: state.matches('connecting'),
    isReconnecting: state.matches('reconnecting'),
    isError: state.matches('error'),
    canReconnect:
      state.context.reconnectAttempts <
      state.context.maxReconnectAttempts,

    // データ
    messages,
    onlineUsers,
    currentUser,
    connectionStatus,
    error: state.context.lastError,

    // アクション
    connect,
    disconnect,
    sendChatMessage,
    reconnect,
  };
};

チャットコンポーネントを作成します。

typescript// components/ChatRoom.tsx
import React, { useState, useRef, useEffect } from 'react';
import { useWebSocketChat } from '../hooks/useWebSocketChat';

export const ChatRoom: React.FC = () => {
  const [messageInput, setMessageInput] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);

  const {
    isConnected,
    isConnecting,
    isError,
    messages,
    onlineUsers,
    currentUser,
    error,
    connect,
    disconnect,
    sendChatMessage,
    reconnect,
  } = useWebSocketChat();

  // 新しいメッセージが追加されたら自動スクロール
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({
      behavior: 'smooth',
    });
  }, [messages]);

  const handleConnect = () => {
    const user = {
      id: 'user-' + Date.now(),
      username:
        'ユーザー' + Math.floor(Math.random() * 1000),
      isOnline: true,
    };
    connect('ws://localhost:8080/chat', user);
  };

  const handleSendMessage = (e: React.FormEvent) => {
    e.preventDefault();
    if (messageInput.trim() && isConnected) {
      sendChatMessage(messageInput.trim());
      setMessageInput('');
    }
  };

  const getConnectionStatusDisplay = () => {
    if (isConnecting) return '接続中...';
    if (isConnected) return '接続済み';
    if (isError) return 'エラー';
    return '未接続';
  };

  return (
    <div className='chat-room'>
      <div className='chat-header'>
        <h2>リアルタイムチャット</h2>
        <div className='connection-info'>
          <span
            className={`status ${
              isConnected ? 'connected' : 'disconnected'
            }`}
          >
            {getConnectionStatusDisplay()}
          </span>

          {!isConnected && !isConnecting && (
            <button onClick={handleConnect}>接続</button>
          )}

          {isConnected && (
            <button onClick={disconnect}>切断</button>
          )}

          {isError && (
            <button onClick={reconnect}>再接続</button>
          )}
        </div>
      </div>

      <div className='chat-body'>
        <div className='messages-container'>
          {messages.map((message) => (
            <div
              key={message.id}
              className={`message ${message.type} ${
                message.userId === currentUser?.id
                  ? 'own'
                  : 'other'
              }`}
            >
              <div className='message-header'>
                <span className='username'>
                  {message.username}
                </span>
                <span className='timestamp'>
                  {message.timestamp.toLocaleTimeString()}
                </span>
              </div>
              <div className='message-content'>
                {message.content}
              </div>
            </div>
          ))}
          <div ref={messagesEndRef} />
        </div>

        <div className='online-users'>
          <h3>オンラインユーザー ({onlineUsers.length})</h3>
          <ul>
            {onlineUsers.map((user) => (
              <li key={user.id} className='user'>
                <span className='username'>
                  {user.username}
                </span>
                <span className='online-indicator'></span>
              </li>
            ))}
          </ul>
        </div>
      </div>

      {isError && error && (
        <div className='error-banner'>エラー: {error}</div>
      )}

      {isConnected && (
        <form
          onSubmit={handleSendMessage}
          className='message-form'
        >
          <input
            type='text'
            value={messageInput}
            onChange={(e) =>
              setMessageInput(e.target.value)
            }
            placeholder='メッセージを入力...'
            disabled={!isConnected}
          />
          <button
            type='submit'
            disabled={!isConnected || !messageInput.trim()}
          >
            送信
          </button>
        </form>
      )}
    </div>
  );
};

WebSocket 接続の状態遷移を以下の図で表現します。

mermaidstateDiagram-v2
    [*] --> Disconnected: 初期状態

    Disconnected --> Connecting: CONNECT

    Connecting --> Connected: 接続成功
    Connecting --> Error: 接続失敗
    Connecting --> Disconnecting: 手動切断

    Connected --> Reconnecting: 予期しない切断
    Connected --> Disconnecting: 手動切断
    Connected --> Error: 接続エラー

    Reconnecting --> Connecting: 再接続試行
    Reconnecting --> Error: 再接続失敗
    Reconnecting --> Disconnecting: 手動切断

    Error --> Connecting: 手動再接続
    Error --> Connecting: 新規接続

    Disconnecting --> Disconnected: 切断完了

    note right of Connected: メッセージ送受信可能
    note right of Reconnecting: 自動再接続中
    note right of Error: 手動操作が必要

この実装により、WebSocket を使ったリアルタイム通信でも、明確な状態管理と堅牢なエラーハンドリングが実現できます。接続状態の可視化や自動再接続機能により、ユーザーに優れた体験を提供できるでしょう。

まとめ

アーキテクチャの利点とトレードオフ

XState と Jotai を組み合わせたアーキテクチャには、明確な利点がある一方で、導入時に考慮すべきトレードオフも存在します。

主な利点

  • 状態遷移の明文化: XState によりアプリケーションの状態フローが明確になり、予期しないバグを大幅に削減できます
  • 優れたテスタビリティ: 状態遷移が厳密に定義されているため、テストケースの作成と実行が容易になります
  • 保守性の向上: コードの可読性が高まり、新しいメンバーでも迅速に理解できる構造になります
  • パフォーマンスの最適化: Jotai の細粒度な再レンダリングにより、大規模なアプリケーションでも高いパフォーマンスを維持できます

考慮すべきトレードオフ

学習コストは避けて通れない課題です。特に XState は概念的な理解が必要で、チームメンバー全員が習得するまでに時間がかかる場合があります。また、シンプルな状態管理には過剰な設計になる可能性もあります。

開発初期のセットアップコストも考慮が必要です。従来の useState ベースの実装と比べて、初期の構築に時間がかかりますが、長期的には開発効率が大幅に向上するでしょう。

適用場面の判断基準

XState × Jotai アーキテクチャの導入を検討すべき場面を明確にしておくことが重要です。

導入を強く推奨する場面

複雑な非同期処理を多用するアプリケーション、例えばリアルタイム通信やマルチステップのワークフローがある場合は、このアーキテクチャの恩恵を最大限に受けられます。また、状態遷移が多岐にわたる機能(認証フロー、決済処理、ゲームロジックなど)でも効果的です。

チーム開発において状態管理の一貫性を保ちたい場合や、長期的な保守性を重視する場面でも適用価値が高いでしょう。

慎重に検討すべき場面

シンプルな CRUD アプリケーションや、状態遷移が少ないプロジェクトでは、導入コストの方が高くつく可能性があります。また、プロジェクトの期間が短い場合や、チームのスキルレベルを考慮して判断することが重要です。

運用上の注意点

実際の運用において注意すべきポイントをまとめておきましょう。

開発フェーズでの注意点

XState マシンの設計は慎重に行う必要があります。後からの状態追加や変更はコストが高くなるため、要件定義段階でしっかりと状態遷移を検討しましょう。また、Jotai アトムの粒度設計も重要で、適切な単位でアトムを分割することでパフォーマンスと保守性を両立できます。

デバッグとモニタリング

XState には優れた開発者ツールが用意されています。XState Inspector を活用することで、リアルタイムで状態遷移を可視化でき、デバッグ効率が大幅に向上します。Jotai も dev tools で状態の変更を追跡できるため、これらのツールを積極的に活用しましょう。

チーム開発での運用

状態機械の設計はチーム全体で共有し、ドキュメント化することが重要です。新しいメンバーが参加した際の学習コストを下げるため、サンプルコードやベストプラクティス集を整備しておくとよいでしょう。

このアーキテクチャを導入することで、React アプリケーションの状態管理は劇的に改善されます。複雑な要件にも柔軟に対応でき、長期的な開発効率の向上につながるでしょう。適切な場面で活用することで、より堅牢で保守性の高いアプリケーションを構築できるはずです。

関連リンク

公式ドキュメント

開発ツール

  • XState Visualizer - XState マシンを視覚的に設計・確認できるオンラインツールです
  • XState Inspector - ランタイムでの XState 状態遷移をデバッグできる開発者ツールです
  • Jotai DevTools - Jotai アトムの状態変化を監視できる開発者ツールです

参考記事・チュートリアル