useEffect×fetch:通信・状態更新・エラー処理の責務分離

Reactの副作用処理の主軸であるuseEffectは、データフェッチや外部リソースへのアクセス、状態の初期化、イベントリスナーの登録・解除といった多くの用途で利用されます。特に非同期通信(fetch等)を扱う際は、通信処理・状態更新・エラー処理が一箇所に集約される構造になりやすく、結果として可読性・保守性・テスト容易性が大きく損なわれる原因となります。

本節では、useEffectfetchを組み合わせた典型的な構造を取り上げ、責務を整理するためのレビュー観点とリファクタ指針を解説します。

状態更新・通信・UI責務の混在例

まずは、よくある実装例を見てみましょう。

useEffectに複数責務が混在したコード
export const UserList = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    fetch('/api/users')
      .then((res) => res.json())
      .then(setUsers)
      .catch(() => setError('データ取得に失敗しました'))
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <p>読込中...</p>;
  if (error) return <p>{error}</p>;

  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
};

このコードには以下のような構造的課題が存在しています。

  • useEffect内で通信処理・状態変更・UIの状態切替条件(loading/error)が同時に制御されている
  • 通信の失敗時の処理(catch)が限定的で、UI側の責務と密結合している
  • fetch呼び出しがコンポーネントに埋め込まれており、再利用性もテスト性も乏しい
Comment
@Reviewer: 通信処理・状態の初期化・UIへの反映がすべて同一Effect内に記述されており、各責務の分離が不十分です。データ取得ロジックを別Hookに抽出し、UI構造との結合度を下げることで、構造の読みやすさと保守性が向上します。
useEffectとは

useEffectはReactコンポーネントの副作用を制御するためのHook。コンポーネントのマウント・更新・アンマウントに応じて副作用処理(API通信、購読、DOM操作など)を定義できる。

通信ロジックの分離方針と命名

責務分離の第一歩は、通信ロジックの抽出です。ただし、単に関数として分離するのではなく、再利用を前提としない画面ローカルのHookとして定義し、状態や副作用を内部に持たせる構成が適切です。

useFetchUsers.ts
export const useFetchUsers = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let ignore = false;
    setLoading(true);

    fetch('/api/users')
      .then((res) => res.json())
      .then((data) => {
        if (!ignore) setUsers(data);
      })
      .catch(() => {
        if (!ignore) setError('データ取得に失敗しました');
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    return () => {
      ignore = true;
    };
  }, []);

  return { users, loading, error };
};
UserList.tsx
export const UserList = () => {
  const { users, loading, error } = useFetchUsers();

  if (loading) return <p>読込中...</p>;
  if (error) return <p>{error}</p>;

  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
};
Comment
@Reviewer: 状態と通信ロジックがHook内に適切に抽出され、UI構造との結合が薄くなったことで、表示側コンポーネントの読みやすさが向上しています。再利用よりも責務の明確化を優先した構成として妥当です。

エラー処理の責務を分離するHook構造

通信処理とエラー状態の管理は、ユーザー体験に直結するため、レビュー時にエラー責務が明示されているかも重要です。以下は、useErrorBoundary的な構造の導入例です。

useApiError.ts
export const useApiError = () => {
  const [error, setError] = useState<string | null>(null);

  const handleError = (e: unknown) => {
    if (e instanceof Error) {
      setError(e.message);
    } else {
      setError('不明なエラーが発生しました');
    }
  };

  return { error, handleError };
};
useFetchUsers.ts(修正後)
export const useFetchUsers = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const { error, handleError } = useApiError();

  useEffect(() => {
    let cancelled = false;

    const load = async () => {
      setLoading(true);
      try {
        const res = await fetch('/api/users');
        if (!cancelled) {
          const data = await res.json();
          setUsers(data);
        }
      } catch (e) {
        if (!cancelled) handleError(e);
      } finally {
        if (!cancelled) setLoading(false);
      }
    };

    load();
    return () => {
      cancelled = true;
    };
  }, [handleError]);

  return { users, loading, error };
};

責務分離後の構造

UML Diagram

レビュー観点:通信責務の分離判断

責務を適切に分離できているかのチェックポイントは以下の通りです。

  • useEffect内に複数の状態更新やフラグ管理が混在していないか
  • 通信処理がUI構造と過度に結合していないか
  • エラー処理が表示責務と混在していないか
  • 副作用の単位として再利用よりも可読性・保守性が担保されているか

通信と状態更新が一体化した構造は初期実装としては素早く作れますが、成長するコードベースにおいてはロジックの追跡と変更コストの温床になります。レビューでは、再利用を前提としない構造であっても、責務の最小単位に分割されているかに注目することが重要です。