Reactにおいて、useEffect の中で非同期処理を扱うケースは非常に多く見られます。データ取得、初期化処理、APIへの通信など、副作用の大半が非同期である以上、それをどのように構造として記述するかは極めて重要です。

しかし、現場では次のような設計ミスが頻発します:

  • async/awaitの使い方が不適切
  • 非同期キャンセル処理が不完全
  • useEffectの再実行時に非同期関数が暴走する
  • ステート更新と非同期結果が同期せず、race conditionが発生

本記事では、useEffect における非同期処理の構造的ミスとそのレビュー観点を明確化し、レビューアーとしての設計判断力を強化します。

useEffectの中でasync functionを直接使ってはいけない理由

Reactでは、useEffect 自体は同期関数である必要があり、直接 async を付けることは構文上できてしまいますが、推奨されていません。

非推奨な構造
useEffect(async () => {
  const res = await fetch("/api/data");
  const json = await res.json();
  setData(json);
}, []);
コードレビュー
@Reviewer: `useEffect` に直接 `async` を付けると、副作用関数自体がPromiseを返してしまい、Reactのクリーンアップ処理と競合する恐れがあります。即時関数でラップする形式を取ってください。

これは構造的に「同期のはずのEffectが非同期になる」という設計破綻を引き起こします。

正しい非同期構造の基本形

推奨される構造
useEffect(() => {
  const fetchData = async () => {
    const res = await fetch("/api/data");
    const json = await res.json();
    setData(json);
  };
  fetchData();
}, []);

または、即時関数を使った形式も構造的に問題ありません。

即時実行関数を使った形式
useEffect(() => {
  (async () => {
    const res = await fetch("/api/data");
    const json = await res.json();
    setData(json);
  })();
}, []);

非同期処理のキャンセル設計がないケース

パターン1:アンマウント後にもsetStateしてしまう

アンマウント後の更新
useEffect(() => {
  fetch("/api/data")
    .then((res) => res.json())
    .then((data) => setData(data));
}, []);
コードレビュー
@Reviewer: コンポーネントがアンマウントされた後に `setData` が呼ばれる可能性があります。キャンセル制御を追加してください。

このような構造は、クリーンアップがないことでメモリリークや警告を引き起こす要因になります。

AbortControllerを使ったキャンセル対応

AbortControllerによる非同期制御
useEffect(() => {
  const controller = new AbortController();

  fetch("/api/data", { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => setData(data))
    .catch((err) => {
      if (err.name !== "AbortError") {
        console.error(err);
      }
    });

  return () => {
    controller.abort();
  };
}, []);
コードレビュー
@Reviewer: 非同期処理をAbortControllerで制御することで、再実行やアンマウント時の競合が防止されています。React 18以降では特に推奨される構造です。

複数回再実行される非同期Effectの危険性

依存配列に変数が含まれている場合、再実行によって古いPromiseが新しい更新を上書きするrace conditionが発生します。

再実行による非同期の競合
useEffect(() => {
  let isStale = false;
  fetch(`/api/item/${id}`)
    .then((res) => res.json())
    .then((data) => {
      if (!isStale) setItem(data);
    });

  return () => {
    isStale = true;
  };
}, [id]);
コードレビュー
@Reviewer: 再実行が必要なEffectにおいて、古い非同期処理の結果を無視するための `isStale` フラグは、構造上の妥当性を保つために有効です。

可視化:非同期処理と再実行による競合構造

UML Diagram

カスタムHookとして非同期責務を抽出する方法

非同期処理とロジックが混在している場合、責務をカスタムHookに切り出すと設計が明確になります。

カスタムHook構造
function useItem(id: string) {
  const [item, setItem] = useState(null);

  useEffect(() => {
    let stale = false;
    fetch(`/api/item/${id}`)
      .then((res) => res.json())
      .then((data) => {
        if (!stale) setItem(data);
      });
    return () => {
      stale = true;
    };
  }, [id]);

  return item;
}
利用側
const item = useItem("abc123");
コードレビュー
@Reviewer: 責務をHookとして切り出すことで、副作用の発火条件と状態保持の責任が明確化されており、再利用性とテスト性が高い構造です。

レビュー観点チェックリスト

非同期処理は「動いているように見える」ことが多く、構造の欠陥が後になって問題化します。レビューアーは即時実行の見た目に惑わされず、設計上の競合と責任範囲を読み解く視点を持つことが求められます。