【実務・中級編】 非同期処理におけるレースコンディションの回避 – React実践ガイド

こんにちは。チームのコードレビューをしていて、一番「あぁ、惜しいな、でもこれジュニアの時は絶対ハマるやつだ」って胸が締め付けられる瞬間があるんだよね。

それが、`useEffect`の中での非同期処理とレースコンディション(競合状態)の放置。

「APIからデータを取ってきてstateに入れる」っていう、フロントエンドで一番数こなす処理なのに、ここを雑に実装しているがために、画面がカクカクした時に古いデータで最新のデータが上書きされてしまったり、最悪の場合はアンマウント済みのコンポーネントの状態を更新しようとしてReactからお説教(Warning)を食らう……。

中級へのステップアップを目指す君なら、もう「とりあえず動くコード」から卒業して、「意図通りに状態と副作用が美しく調和するコード」を書けるようになりたいはずだ。

今回は、実務で死ぬほど遭遇するこの「レースコンディション」を、クリーンアップ関数とフラグ管理で綺麗にねじ伏せる方法を、ブラウザの裏側の動きも含めて徹底的に解説していくよ。コーヒーでも飲みながら、じっくり読んでいってほしい。

—

なぜ、レースコンディションは起きるのか?(ブラウザの裏側で何が起きているか)

まず、敵を知ることから始めよう。
例えば、ユーザーがセレクトボックスで「カテゴリA」を選んだあと、すぐに気が変わって「カテゴリB」を選んだとする。

この時、ブラウザのネットワークタブを覗くと、何が起きているだろうか?
1. `GET /api/items?category=A` のリクエストが飛ぶ
2. すぐに `GET /api/items?category=B` のリクエストが飛ぶ

ネットワークの世界はジャングルだ。サーバーの負荷やパケットのルーティングによっては、後から投げた「カテゴリB」のレスポンスが先に返ってきて、その後に、遅れていた「カテゴリA」のレスポンスがのっそりと返ってくるなんてことが日常茶飯事に起きる。

ここで、何も考えずに `useEffect` の中でデータを取得し、そのまま `setItems` を呼んでいるとどうなるか。

[時刻 t1] カテゴリAリクエスト送信
[時刻 t2] カテゴリBリクエスト送信
[時刻 t3] カテゴリBレスポンス到着 → setItems(Bのデータ) 🚀画面はカテゴリBに!
[時刻 t4] カテゴリAレスポンス到着 → setItems(Aのデータ) 😱画面が勝手にカテゴリAに戻った!

これがレースコンディション(競合状態)だ。ユーザーからすれば「えっ、さっきBを選んだのに、なんでAのデータが表示されてるの?バグってない?」という最悪の体験になる。

Reactの `useEffect` は魔法の箱じゃない。非同期処理の「完了する順序」までは保証してくれないんだ。だからこそ、僕らフロントエンドエンジニアが手綱を握ってコントロールしてやる必要がある。

—

解決策:クリーンアップ関数と「フラグ管理」の美学

この問題をスマートに解決する定番アプローチが、クリーンアップ関数を利用したフラグ管理(`isCancelled`パターン)だ。

React 18以降のStrict Mode(開発環境での二重マウント検証)や、ユーザーの素早い入力切り替えにおいて、`useEffect` のクリーンアップは命綱になる。

概念はシンプル。
「このエフェクトが次に走るか、あるいはコンポーネントが消え去るまでの間、今から私が撃つ非同期弾の着弾(state更新)は『無効』だというフラグを立てておく」これだけだ。

百聞は一見にしかず。実務でそのまま使える、洗練されたコードを見てみよう。

実装サンプル:安全なデータフェッチング・コンポーネント

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

// モックのAPI関数(通信にランダムな遅延を持たせる)
const fetchMockApi = async (keyword: string): Promise => {
const delay = Math.random() 2000 + 500; // 0.5秒〜2.5秒の遅延
await new Promise((resolve) => setTimeout(resolve, delay));

// あえて古いリクエストが遅く返ってきた感を演出するためのレスポンス
return [`${keyword}の検索結果 1`, `${keyword}の検索結果 2`, `${keyword}の検索結果 3`];
};

export const SafeSearchComponent: React.FC = () => {
const [query, setQuery] = useState(‘React’);
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);

useEffect(() => {
// 1. このエフェクトスコープ内だけの固有のフラグを定義
let isIgnored = false;

const loadData = async () => {
setIsLoading(true);
try {
// APIリクエストを発火
const data = await fetchMockApi(query);

// 2. 【最重要】非同期処理が完了した瞬間、
// このエフェクトがすでに古くなっていないか(あるいはクリーンアップされていないか)をチェック
if (!isIgnored) {
setResults(data);
}
} catch (error) {
if (!isIgnored) {
console.error(‘データ取得に失敗しました’, error);
}
} finally {
// ローディング状態の更新も、最新のリクエストでのみ行う
if (!isIgnored) {
setIsLoading(false);
}
}
};

loadData();

// 3. クリーンアップ関数
// 依存配列の値(query)が変わる直前、またはコンポーネントのアンマウント時に実行される
return () => {
isIgnored = true; // 「このリクエストの結果はもう古いから捨ててくれ」とサインを送る
};
}, [query]); // queryが変わるたびに前のエフェクトのクリーンアップが走る

return (

レースコンディション回避のデモ

setQuery(e.target.value)}
placeholder=”キーワードを入力…”
style={{ padding: ‘8px’, fontSize: ’16px’, marginRight: ’10px’ }}
/>
{isLoading &&

読み込み中…

}

    {results.map((item, index) => (

  • {item}
  • ))}

);
};

—

コードの解説:なぜこの書き方がプロフェッショナルなのか?

このコードのポイントを、シニアの視点からいくつか解説しておこう。

1. クロージャの特性をハックした `isIgnored` 変数

JavaScriptのクロージャの仕組みにより、`useEffect` のコールバック関数内で宣言された `let isIgnored = false;` は、その時々のレンダリング(エフェクトのインスタンス)固有のものとして保持される。
ユーザーが `query` を変更すると、まず古いエフェクトのクリーンアップ関数が走り、古い方の `isIgnored` が `true` に書き換わる。その後に新しいエフェクトが走り、新しい `isIgnored`(初期値 `false`)が生成されるんだ。

2. 「アンマウント時」だけでなく「再実行時」にも効く

ここを勘違いしている人が多いんだけど、`useEffect` のクリーンアップ関数は「コンポーネントが画面から消える時(アンマウント時)」だけに走るわけじゃない。
依存配列の値が変わり、エフェクトが再実行される「直前」にも必ず走る。
だからこそ、今回のケースのように「入力値が高速に変更されてリクエストが連続する」状況において、過去の亡霊のような遅延レスポンスを完璧にシャットアウトできるというわけだ。

3. メモリリーク警告(Can’t perform a React state update…)の防止

非同期処理が完了して `setState` を呼ぶ頃には、ユーザーが別のページに移動していてコンポーネントが消滅している、という事故もよくある。
この `isIgnored` パターン(あるいはよく `isCancelled` とも呼ばれる)を入れておけば、アンマウント後のコンポーネントに対して `setState` が走るのを防げるため、コンソールを無駄なWarningで汚さずに済む。

—

実務でさらに一歩進んだアドバイス

ここまで読めば、素の `useEffect` を使った非同期処理のコントロール方法についてはバッチリだ。

ただ、実際のプロダクション開発、特に複雑なダッシュボードや、ユーザーの入力にリアルタイムで追従する検索機能(Autocompleteなど)を作る場合、毎回このボイラープレート(定型コード)を自前で書くのは、正直言って少し退屈だし、バグの温床になりやすい。

もしプロジェクトの規模が大きくなってきたら、以下のようなアプローチへの移行を検討してみてほしい。

1. データフェッチングライブラリの活用

  • TanStack Query (React Query) や SWR を使おう。これらは、内部で競合状態の自動キャンセルや、重複リクエストの排除、キャッシュ管理をよしなにやってくれる。現代のReact開発において、APIコールに素の `useEffect` を直接書く機会は、実はどんどん減ってきているのが現実だ。

2. AbortControllerの利用

  • ブラウザ標準の `fetch` APIを使う場合は、`AbortController` を使うことで、ネットワークレベル(HTTP通信そのもの)でリクエストをキャンセルできる。

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

fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(data => setData(data))
.catch(err => {
if (err.name !== ‘AbortError’) {
// キャンセル以外のエラー処理
}
});

return () => controller.abort(); // 通信自体をブツ切りにする
}, [url]);

まとめ

今回の話をサクッとまとめよう。

  • 非同期処理のレスポンス順序は信用するな。 ネットワークはいつだって気まぐれだ。
  • `useEffect` のクリーンアップ関数は、アンマウント時だけでなく「依存値変更による再実行の直前」にも走る。
  • `isIgnored` などのフラグ管理を挟むことで、古いレスポンスによる状態の汚染を防ぐことができる。

「動けばいいや」で書いたコードと、「裏側のメカニズムを理解して堅牢に組まれた」コードの差は、アプリが大きくなった時に残酷なほど表面化する。ぜひ、今日の知識を君のプロジェクトのコードレビューや新規実装に活かしてほしい。

それじゃあ、また次の現場でお会いしよう。ハッピー・コーディング!

コメント

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