【実務・中級編】 副作用の競合状態(Race Condition)のテスト – React実践ガイド

useEffectの「魔物」を飼い慣らす:Race Condition(競合状態)を制するテスト戦略

フロントエンド開発の現場で、もっとも頭を抱えるバグの一つが「Race Condition(競合状態)」です。特にReactの`useEffect`内でAPIリクエストを投げているとき、ユーザーが高速でボタンを連打したり、タブを切り替えたりすると、「後から投げたリクエストが先に返ってきたのに、古いデータで上書きされる」という悪夢のような現象が発生します。

今日は、この泥臭い問題にどう立ち向かい、テストで確実に封じ込めるかについて、現場の知見を共有しましょう。

—

なぜ「競合」は起きるのか?

ブラウザの裏側では、JavaScriptの非同期処理がイベントループの上で淡々と実行されています。`useEffect`の中で`fetch`を呼び出すとき、Reactは「副作用が実行されたこと」は把握していますが、その中で投げたリクエストの「戻り順序」までは保証してくれません。

リクエストA(重い)が先に発火し、次にリクエストB(軽い)が発火したとします。ネットワークの気まぐれでBが先に帰ってくると、最後にAの結果が画面を上書きし、UIとデータの整合性が崩壊します。これが競合の正体です。

クリーンアップ関数という「盾」

この問題を解決する唯一の正攻法は、「最新のリクエスト以外は無視する」というルールをクリーンアップ関数で実装することです。

実践的なサンプルコード

まずは、現場でそのまま使える、競合を防ぐためのクリーンアップ関数付きのフック実装です。

import { useState, useEffect } from ‘react’;

// ユーザーIDを受け取ってデータを取得するコンポーネント
export const UserProfile = ({ userId }: { userId: string }) => {
const [data, setData] = useState(null);

useEffect(() => {
// 1. フラグを用意する(これが重要!)
let active = true;

const fetchData = async () => {
const result = await fetch(`/api/users/${userId}`).then(res => res.json());

// 2. クリーンアップ関数が実行されたら、この処理は無視する
if (active) {
setData(result);
}
};

fetchData();

// 3. クリーンアップ関数:コンポーネントのアンマウント時、
// またはuserIdが変わった時に実行される
return () => {
active = false;
};
}, [userId]);

return

{data ? data.name : ‘Loading…’}

;
};

—

「本当に防げているか?」をテストで証明する

「なんとなく動いている気がする」ではプロ失格です。テストコードを書いて、競合が起きたときに古いリクエストが破棄されることを物理的に証明しましょう。

ここでは、JestとReact Testing Libraryを使った、現場の定番手法を紹介します。

import { render, screen, act } from ‘@testing-library/react’;
import { UserProfile } from ‘./UserProfile’;

// APIモック(わざとレスポンス速度を変える)
const mockFetch = (id: string, delay: number) =>
new Promise((resolve) =>
setTimeout(() => resolve({ name: `User ${id}` }), delay)
);

test(‘高速なリクエストが遅いリクエストを上書きしないこと’, async () => {
// グローバルfetchをジャックする
jest.spyOn(global, ‘fetch’).mockImplementation((url: string) => {
if (url.includes(‘1’)) return mockFetch(‘1’, 100); // 遅い
return mockFetch(‘2’, 10); // 速い
});

const { rerender } = render();

// ユーザーIDを1から2へ高速で切り替える
rerender();

// ここで少し待つと、本来なら「1」の結果が到着するはずだが
// クリーンアップ関数のおかげで、状態は「User 2」のままになるはず
await act(async () => {
await new Promise(r => setTimeout(r, 150));
});

expect(screen.getByText(‘User 2’)).toBeInTheDocument();
// 「User 1」という文字列が存在しないことを確認する(競合していない証明)
expect(screen.queryByText(‘User 1’)).not.toBeInTheDocument();
});

—

シニアからのアドバイス:現場での運用指針

このコードを書いて終わりではありません。チーム開発においては、以下の3点を意識してください。

1. React 18のStrict Modeを味方につける: 開発環境では`useEffect`が2回呼ばれます。これは「クリーンアップ関数を正しく実装しているか」をチェックするためのReactからのテストです。もしここでバグが出るなら、そもそも設計が間違っています。
2. `AbortController` を使う: 上記のサンプルは`active`フラグによる制御ですが、よりモダンな環境であれば `AbortController` を使ってリクエスト自体をキャンセルするのがベストです。ブラウザの通信リソースを無駄にしないためです。
3. 複雑ならTanStack Query(React Query)へ: `useEffect`で非同期処理を管理するのは、実はかなり難易度が高い仕事です。競合管理、キャッシュ、再試行……これらを自前で実装し続けるのは「車輪の再発明」です。プロダクトが成長したら、迷わず専用ライブラリに移行しましょう。

「useEffectは副作用を扱う場所」というのは半分正解ですが、半分は罠です。「副作用をいかにクリーンに破棄するか」、これこそがReactエンジニアの腕の見せ所です。

今日解説した手法を、ぜひあなたのプロジェクトのテストスイートに組み込んでみてください。これでまた一つ、深夜のデバッグから解放されるはずです。頑張りましょう。

コメント

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