【実務・中級編】 useEffect内での非同期処理の扱い – React実践ガイド

やあ、調子はどうだい?
最近、コードレビューをしていて一番「あー、また踏み抜いてるな」って思うポイントがどこだか分かるかい?そう、今回のテーマである `useEffect` 内での非同期処理の扱い だ。

中級に差し掛かったエンジニアが、なぜか「とりあえず `useEffect` のコールバックに `async` つけとけばいっか」という魔界の呪文を唱えて自爆するシーンを、俺はもう数え切れないほど見てきた。

今日は、なぜそれがタブーなのかというReactの心臓部の話から、ブラウザが裏側でどう動いているか、そして現場で私たちがどうやってこの非同期の荒波をスマートに乗りこなすべきか、シニアの視点からみっちり伝授しよう。コーヒーでも飲みながら、じっくり聞いてくれ。

—

なぜ `useEffect` の第一引数を `async` にしてはいけないのか?

まず最初に、このコードを見てほしい。君のチームの誰かが、いや、もしかしたら過去の君自身が書いたかもしれないコードだ。

// 【アンチパターン】絶対にやってはいけない書き方
useEffect(async () => {
const data = await fetchUserData(userId);
setUser(data);
}, [userId]);

一見すると、何がダメなのか分からないかもしれない。「データ取れてるじゃん」って思うよな?
だが、これこそがReactのライフサイクルを盛大にバグらせる原因なんだ。

Reactが期待している「戻り値」の正体

JavaScriptの `async` 関数は、何を返すか知っているかい? そう、無条件に「Promiseオブジェクト」を返すんだ。

一方で、Reactの `useEffect` が第一引数の関数に求めている戻り値は一体何だったか?
それは、コンポーネントがアンマウントされる時や、副作用が再実行される直前にクリーンアップを行うための「クリーンアップ関数(関数)」だ。

つまり、`useEffect(async () => {})` と書いた瞬間、Reactは「おっ、クリーンアップ関数としてPromiseが返ってきたぞ?よし、コンポーネントが消える時にこのPromiseを関数として呼び出すか!」と盛大な勘違いをする。
結果として、アンマウント時に `TypeError: 〇〇 is not a function` というエラーがコンソールに爆誕するわけだ。ブラウザは悪くない。騙したこっちが悪い。

—

ブラウザの裏側とReactの同期メカニズム

じゃあ、もう少し解像度を上げて、ブラウザが裏側でどう動いているのかを覗いてみよう。

Reactのレンダリングパイプラインは、大まかに以下のステップで進む。

1. Render Phase(レンダリングフェーズ): コンポーネントが実行され、新しい仮想DOMツリーが生成される(ここは純粋関数であるべきで、副作用は禁止)。
2. Commit Phase(コミットフェーズ): Reactが実際のDOMを更新する。
3. Paint(ブラウザの描画): ブラウザが画面をピクセルとして描き出す。
4. Layout Effects (`useLayoutEffect`) の実行: ペイント前に同期的に実行される。
5. Effects (`useEffect`) の非同期実行: ブラウザの描画が完了した後、メインスレッドが空いたタイミングで非同期的に実行される。

ここで重要なのは、`useEffect` 自体は「描画の邪魔をしないように後回しで実行される」ということだ。
しかし、その中でさらに `async/await` を使って非同期処理を待ち受けようとすると、Reactのライフサイクル管理と、JavaScriptの非同期タスクキュー(Microtask Queue)のタイミングが複雑に絡み合う。

副作用関数自体を `async` にしてしまうと、Reactは副作用の完了タイミングを完全にコントロールできなくなる。これが、予期せぬ競合状態(Race Condition)や、メモリリークの温床になるんだ。

—

現場の正解:関数内部で `async` 関数を定義して即時実行する

じゃあ、どう書くのが正解なのか。
答えはシンプルだ。`useEffect` のコールバック自体は同期関数のままにしておき、その内部で `async` な関数を定義して、即座に呼び出す(IIFEや通常の関数定義)。

百聞は一見にしかず。実務でそのまま使える、最高にクリーンなサンプルコードを見せよう。

import React, { useState, useEffect } from ‘react’;

export const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
// 1. 競合状態(Race Condition)を防ぐためのフラグ
let isMounted = true;

// 2. useEffectの内部でasync関数を定義する
const fetchUserData = async () => {
setLoading(true);
setError(null);

try {
const response = await fetch(`https://api.example.com/users/${userId}`);

if (!response.ok) {
throw new Error(‘データの取得に失敗しました’);
}

const data = await response.json();

// 3. 非同期処理が完了した時点で、コンポーネントがまだマウントされていれば状態を更新
if (isMounted) {
setUser(data);
}
} catch (err) {
if (isMounted) {
setError(err.message);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};

// 4. 定義した関数を即座に実行する
fetchUserData();

// 5. クリーンアップ関数でフラグを倒し、アンマウント後の状態更新を防ぐ
return () => {
isMounted = false;
};
}, [userId]); // userIdが変わるたびに安全に再フェッチされる

if (loading) return

読み込み中…

;
if (error) return

エラー: {error}

;
if (!user) return null;

return (

{user.name}

{user.email}

);
};

—

シニアが教える、現場の泥臭い知見とTips

上記のコードだけでも十分プロダクションレベルだが、実務の現場ではさらに一歩踏み込んだ配慮が必要になる。ここからは、現場で生き残るための実践知をいくつか授けよう。

1. 競合状態(Race Condition)への対策

ユーザーが素早くページを行き来したり、`userId` が `1` から `2` に瞬時に切り替わった時を想像してほしい。
ネットワークの遅延によっては、先にリクエストした `userId=1` のレスポンスが、後からリクエストした `userId=2` のレスポンスよりも遅く返ってくることがある。

もし上記の `isMounted` フラグや、後述の `AbortController` を使っていないと、古いデータ(userId=1)で新しい画面が上書きされてしまう。これはバグの温床だ。

2. `AbortController` による通信のキャンセル

もっとモダンでスマートなやり方として、Fetch APIの `AbortController` を使うのも手だ。コンポーネントがアンマウントされたり、依存配列の値が変わって再フェッチ走る時に、前回の通信自体をぶった斬ることができる。

useEffect(() => {
const controller = new AbortController();
const { signal } = controller;

const fetchData = async () => {
try {
const res = await fetch(`/api/data`, { signal });
const json = await res.json();
setData(json);
} catch (error) {
// キャンセルされたエラーは無視し、それ以外をハンドリング
if (error.name !== ‘AbortError’) {
setError(error);
}
}
};

fetchData();

// クリーンアップでリクエストをアボートする
return () => {
controller.abort();
};
}, [dependency]);

これ、めちゃくちゃクールだろ? 無駄な通信を走らせないから、サーバーにも優しい。

—

まとめ:これだけは覚えておけ

1. `useEffect` の第一引数に `async` は絶対につけるな。 戻り値のPromiseがクリーンアップ関数と誤認されてバグる。
2. 非同期処理を書きたいなら、副作用関数の「中」で `async` 関数を定義して即時実行しろ。
3. アンマウント後や再フェッチ時の状態更新(競合状態)を防ぐために、フラグ管理か `AbortController` を使え。

Reactでの非同期処理の扱いは、最初のうちは回りくどく感じるかもしれない。だけど、この作法をマスターすれば、君の書くコードの堅牢性は一段も二段も跳ね上がる。

さて、理論はここまでだ。早速エディタを開いて、自分のプロジェクトにある怪しい `useEffect(async …)` を駆逐してこい!何か詰まったらいつでも声をかけてくれ。頼もしい仲間からの質問はいつでも大歓迎だ。

コメント

タイトルとURLをコピーしました