Reactの世界へようこそ!フロントエンドの魔術師、降臨!
今日のテーマは、Reactにおける「副作用の競合状態(Race Condition)」をテストで再現し、その解決策であるクリーンアップ関数がちゃんと動いているかを確認する方法です。
「副作用?競合状態?何だか難しそう…」と思ったあなた、大丈夫ですよ!まるで、お気に入りのカフェでまったりおしゃべりするような、そんな優しいトーンで、ていねいに紐解いていきましょう。
副作用って、そもそも何?
Reactのコンポーネントは、基本的には「UIをどう表示するか」に集中しています。でも、実際には「UIの表示」以外にも、色々な「お仕事」をする必要がありますよね?例えば:
- データの取得: サーバーから最新の情報を取ってくる
- DOMの直接操作: フォーカスを当てる、スクロール位置を調整するなど
- タイマーの設定: 一定時間後に何かを実行する(`setTimeout`や`setInterval`)
これらの「UIの表示」以外の、ちょっとした「お仕事」のことを、Reactでは「副作用(Side Effect)」と呼んでいます。
Reactでは、この副作用を管理するために `useEffect` というフックを提供しています。useEffectは、コンポーネントがレンダリングされた後に、指定した処理を実行してくれる便利なヤツなんです。
`useEffect` のお仕事と、ちょっとした落とし穴
`useEffect` の一番よくある使い方は、コンポーネントがマウントされた(画面に表示された)時に、データを取得してくることでしょう。
import React, { useState, useEffect } from ‘react’;
function UserProfile({ userId }) {
const [userData, setUserData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// ユーザー情報を取得する非同期処理を開始
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(data => {
setUserData(data);
setLoading(false);
})
.catch(error => {
console.error(‘データの取得に失敗しました:’, error);
setLoading(false);
});
}, [userId]); // userIdが変わったら、再度データを取得し直す
if (loading) {
return
;
}
return (
{userData.name}
{userData.email}
);
}
このコードでは、`userId` が変わるたびに、新しいユーザー情報を取得しに行きます。これは、コンポーネントが「ユーザーのプロフィールを表示する」という本来の仕事に加えて、「APIからユーザー情報を取得する」という副作用を実行しているわけですね。
さて、ここで問題です。もし、ユーザーがものすごい速さで、次々と他のプロフィールに切り替えたらどうなるでしょう?
競合状態(Race Condition)って、どういうこと?
想像してみてください。あなたは、すごく人気のあるカフェにいます。注文カウンターは一つだけ。
1. あなたは「カフェラテ」を注文します。
2. 店員さんは、あなたの注文を受けて、カフェラテを作り始めます。
3. ところが、あなたが「やっぱり、カプチーノに変更!」と、すぐに注文を変えました。
4. 店員さんは、まだカフェラテを作りかけですが、あなたの変更を受けて、カプチーノを作り始めます。
5. もし、店員さんが先に完成したカフェラテをあなたに渡してきたら…?本来なら、カプチーノが欲しかったのに、カフェラテが来てしまう!
これが、まさに「競合状態(Race Condition)」です。
Reactの `useEffect` で非同期処理(`fetch` など)を連続して実行した場合も、これと似たようなことが起こり得ます。
1. コンポーネントAが表示され、`useEffect` が発火してAPIリクエスト①が開始。
2. ユーザーがすぐに別の画面に遷移し、コンポーネントBが表示される。
3. `useEffect` が再度発火し、APIリクエスト②が開始。
4. もし、APIリクエスト①が、APIリクエスト②よりも先に完了してしまったら?
5. コンポーネントBが表示されているのに、古いデータ(コンポーネントAのために取得されたデータ)で画面が更新されてしまう!
これでは、ユーザーは予期しない表示を見てしまい、混乱してしまいますよね。これが、副作用の競合状態です。
競合状態を「テスト」で再現してみよう!
「でも、そんなに速く画面遷移することあるの?」と思うかもしれませんが、実際に開発していると、意外と遭遇するんです。特に、ユーザーの操作が複雑だったり、ネットワークが不安定だったりする場合です。
では、この「競合状態」を、どうやってテストで確認できるのでしょうか?
ポイントは、「非同期処理を意図的に遅延させて、順番を入れ替える」ことです。
Reactのテストライブラリである `@testing-library/react` を使うと、非同期処理を簡単に扱うことができます。
まずは、テスト用のコンポーネントを用意しましょう。ここでは、先ほどの `UserProfile` コンポーネントを少し改変して、APIのレスポンスを遅延させるような仕組みをテストしやすいようにしてみます。
// src/UserProfile.js (テスト用改変)
import React, { useState, useEffect } from ‘react’;
// ダミーのAPIフェッチ関数。テストで遅延を注入できるようにする。
const fetchUserData = (userId, delay = 500) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`— [API] Fetching data for user ${userId} —`);
resolve({ id: userId, name: `User ${userId}`, email: `user${userId}@example.com` });
}, delay);
});
};
function UserProfile({ userId }) {
const [userData, setUserData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 競合状態を再現するために、userIdごとに一意のIDを生成
const requestID = Math.random();
console.log(`— [Effect] Starting fetch for userId: ${userId}, requestID: ${requestID} —`);
setLoading(true);
setError(null);
fetchUserData(userId) // ここで遅延を注入する(テスト時に調整)
.then(data => {
console.log(`— [Effect] Received data for userId: ${userId}, requestID: ${requestID} —`);
// ここで、このリクエストがまだ有効かチェックする仕組みがあると理想的
setUserData(data);
})
.catch(err => {
console.error(`— [Effect] Error fetching data for userId: ${userId}, requestID: ${requestID} —`, err);
setError(‘データの取得に失敗しました。’);
})
.finally(() => {
// loading状態の更新も、完了したリクエストのものだけ行いたい
// (この単純な例では、まだ実装されていませんが、重要です)
console.log(`— [Effect] Finished processing for userId: ${userId}, requestID: ${requestID} —`);
setLoading(false);
});
// クリーンアップ関数を後で追加します!
return () => {
console.log(`— [Cleanup] Cleaning up effect for userId: ${userId}, requestID: ${requestID} —`);
// ここで、このリクエストをキャンセルする処理を記述します
};
}, [userId]); // userIdが変わったら、再度データを取得し直す
if (loading) {
return
;
}
if (error) {
return
;
}
return (
{userData?.name || ‘データがありません’}
{userData?.email || ”}
);
}
export default UserProfile;
次に、このコンポーネントをテストするファイルを作成します。
// src/UserProfile.test.js
import React from ‘react’;
import { render, screen, waitFor, act } from ‘@testing-library/react’;
import UserProfile from ‘./UserProfile’;
// ダミーAPIの実行を遅延させるためのヘルパー関数
const flushPromisesAndDelay = (ms) => {
return new Promise(resolve => setTimeout(() => resolve(), ms));
};
describe(‘UserProfile’, () => {
// テストケース1: 単一のユーザーIDで正常に表示されるか
test(‘should display user profile correctly for a single userId’, async () => {
render(
expect(screen.getByText(‘読み込み中…’)).toBeInTheDocument();
// APIの完了を待つ
await waitFor(() => {
expect(screen.getByText(‘User 1’)).toBeInTheDocument();
expect(screen.getByText(‘user1@example.com’)).toBeInTheDocument();
});
});
// テストケース2: 競合状態を再現する
test(‘should handle race condition correctly’, async () => {
const { rerender } = render(
// 最初のユーザー(userId=”1″)のデータ取得が開始される
await flushPromisesAndDelay(100); // 少し待って、最初のフェッチが開始したことを確認
// ユーザーがすぐに別のプロフィール(userId=”2″)に切り替える
rerender(
// 最初のユーザー(userId=”1″)のデータ取得が完了する前に、
// 2番目のユーザー(userId=”2″)のデータ取得が完了することを期待する
// ここで、APIの遅延をテストで調整します。
// fetchUserData はデフォルトで 500ms 遅延するので、
// userId=”1″ の完了(500ms)よりも短い時間で userId=”2″ が完了するように、
// userId=”1″ の完了を遅らせる(ダミーAPIのdelayを上書きするなど)か、
// ユーザー切り替えを遅らせるなどの工夫が必要です。
// 今回は、APIの遅延を500msと仮定し、
// ユーザー切り替え後、2番目のAPIが先に完了するシナリオをシミュレートします。
// 実際には、テスト実行環境でAPIの遅延を制御できるとより正確です。
// ユーザー”1″のAPIは500ms、ユーザー”2″のAPIは300msで完了すると仮定
// ユーザー”2″が先に完了するはず
await flushPromisesAndDelay(300); // ユーザー”2″のデータが返ってくるのを待つ
// 画面には、userId=”2″ の情報が表示されているはず
expect(screen.getByText(‘読み込み中…’)).toBeInTheDocument(); // まだ userId=”1″ の処理が残っている可能性
await waitFor(() => {
expect(screen.getByText(‘User 2’)).toBeInTheDocument();
expect(screen.getByText(‘user2@example.com’)).toBeInTheDocument();
});
// 500ms経過後、userId=”1″ のデータがもし来てしまったとしても、
// それは無視されるべき(まだクリーンアップ関数がないため、ここでは画面に表示されてしまう可能性があります)
await flushPromisesAndDelay(200); // 合計 500ms
// 画面に userId=”1″ の情報が表示されていないことを確認したいが、
// 現在の実装では、古いデータが表示されてしまう可能性がある。
// ここで、クリーンアップ関数の重要性が浮き彫りになる!
// expect(screen.queryByText(‘User 1’)).not.toBeInTheDocument(); // クリーンアップがあれば、これは成功するはず
});
});
このテストを実行すると、`UserProfile` コンポーネントが `userId=”1″` のデータを取得中に、すぐに `userId=”2″` に切り替わった場合、`userId=”2″` のデータが画面に表示されるはずです。
しかし、もし `userId=”1″` のデータ取得が `userId=”2″` よりも早く完了してしまったら、`userId=”2″` の情報が表示されるべき画面に、古い `userId=”1″` の情報が表示されてしまう、という問題が発生する可能性があります。
これが、テストで「副作用の競合状態」を再現している状況です。
解決策:クリーンアップ関数で「キャンセル」!
この「競合状態」を防ぐための強力な武器が、`useEffect` の「クリーンアップ関数」です。
`useEffect` は、その第二引数(依存配列)に指定された値が変わって、再度エフェクトが実行される前に、前回の実行で返された関数(クリーンアップ関数)を実行します。
まるで、カフェの店員さんが、新しい注文(カプチーノ)を受け取ったら、まだ作りかけだった前の注文(カフェラテ)を「あ、これはもう大丈夫です!」と片付けるようなイメージです。
では、このクリーンアップ関数を、先ほどの `UserProfile` コンポーネントに適用してみましょう。
// src/UserProfile.js (クリーンアップ関数を追加)
import React, { useState, useEffect } from ‘react’;
// ダミーのAPIフェッチ関数。テストで遅延を注入できるようにする。
const fetchUserData = (userId, delay = 500) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`— [API] Fetching data for user ${userId} —`);
resolve({ id: userId, name: `User ${userId}`, email: `user${userId}@example.com` });
}, delay);
});
};
function UserProfile({ userId }) {
const [userData, setUserData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// 競合状態を制御するためのフラグ
let isComponentMounted = true; // コンポーネントがマウントされているかを示すフラグ
let currentRequestId = null; // 現在のリクエストIDを保持
useEffect(() => {
// 競合状態を再現するために、userIdごとに一意のIDを生成
const requestId = Math.random(); // 今回のリクエストID
currentRequestId = requestId; // 現在のリクエストIDを更新
console.log(`— [Effect] Starting fetch for userId: ${userId}, requestId: ${requestId} —`);
setLoading(true);
setError(null);
fetchUserData(userId) // ここで遅延を注入する(テスト時に調整)
.then(data => {
console.log(`— [Effect] Received data for userId: ${userId}, requestId: ${requestId} —`);
// — ここが重要! —
// データが返ってきたときに、このリクエストがまだ有効(キャンセルされていない)かを確認!
if (isComponentMounted && currentRequestId === requestId) {
setUserData(data);
} else {
console.log(`— [Effect] Ignoring stale data for userId: ${userId}, requestId: ${requestId} (component unmounted or new request active) —`);
}
})
.catch(err => {
console.error(`— [Effect] Error fetching data for userId: ${userId}, requestId: ${requestId} —`, err);
// エラー時も、コンポーネントがマウントされていて、かつ最新のリクエストであればエラーを表示
if (isComponentMounted && currentRequestId === requestId) {
setError(‘データの取得に失敗しました。’);
}
})
.finally(() => {
// loading状態の更新も、完了したリクエストのものだけ行いたい
// コンポーネントがマウントされていて、かつ最新のリクエストであればローディングを解除
if (isComponentMounted && currentRequestId === requestId) {
console.log(`— [Effect] Finished processing for userId: ${userId}, requestId: ${requestId} —`);
setLoading(false);
} else {
console.log(`— [Effect] Not setting loading=false for userId: ${userId}, requestId: ${requestId} (stale request) —`);
}
});
// — クリーンアップ関数 —
return () => {
console.log(`— [Cleanup] Cleaning up effect for userId: ${userId}, requestId: ${requestId} —`);
isComponentMounted = false; // コンポーネントがアンマウントされたことを示す
// ここで、このeffectが開始したリクエストを「キャンセル」する、あるいは「無効」にする
// 具体的には、後続の `setUserData` や `setLoading` が実行されないようにフラグを立てます。
// 今回は `isComponentMounted` と `currentRequestId` で制御しています。
// より高度なキャンセル処理(AbortControllerなど)も可能ですが、まずはこの方法で。
};
}, [userId]); // userIdが変わったら、再度データを取得し直す
if (loading) {
return
;
}
if (error) {
return
;
}
return (
{userData?.name || ‘データがありません’}
{userData?.email || ”}
);
}
export default UserProfile;
そして、このクリーンアップ関数が正しく機能しているかを確認するためのテストも更新しましょう。
// src/UserProfile.test.js (クリーンアップ関数をテスト)
import React from ‘react’;
import { render, screen, waitFor, act } from ‘@testing-library/react’;
import UserProfile from ‘./UserProfile’;
// ダミーAPIの実行を遅延させるためのヘルパー関数
const flushPromisesAndDelay = (ms) => {
return new Promise(resolve => setTimeout(() => resolve(), ms));
};
describe(‘UserProfile’, () => {
// テストケース1: 単一のユーザーIDで正常に表示されるか (変更なし)
test(‘should display user profile correctly for a single userId’, async () => {
render(
expect(screen.getByText(‘読み込み中…’)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(‘User 1’)).toBeInTheDocument();
expect(screen.getByText(‘user1@example.com’)).toBeInTheDocument();
});
});
// テストケース2: クリーンアップ関数によって競合状態が解決されるか
test(‘should prevent stale data from being displayed due to race condition with cleanup’, async () => {
// UserProfile コンポーネントの fetchUserData の遅延をシミュレート
// userId=”1″ のリクエストは 500ms
// userId=”2″ のリクエストは 300ms
// このように、非同期処理の完了順序が入れ替わる状況をテストします。
const { rerender } = render(
// 最初のユーザー(userId=”1″)のデータ取得が開始される
await flushPromisesAndDelay(100); // 少し待って、最初のフェッチが開始したことを確認
// ユーザーがすぐに別のプロフィール(userId=”2″)に切り替える
rerender(
// ユーザー”2″のAPIが完了する (300ms)
await flushPromisesAndDelay(300);
// 画面には、userId=”2″ の情報が表示されているはず
await waitFor(() => {
expect(screen.getByText(‘User 2’)).toBeInTheDocument();
expect(screen.getByText(‘user2@example.com’)).toBeInTheDocument();
});
// 画面に「読み込み中…」が表示されていないことを確認(ローディングが完了しているはず)
expect(screen.queryByText(‘読み込み中…’)).not.toBeInTheDocument();
// 500ms経過後、userId=”1″ のデータ取得が完了する
// クリーンアップ関数が正しく機能していれば、この古いデータは画面に表示されないはず!
await flushPromisesAndDelay(200); // 合計 500ms
// 画面に userId=”1″ の情報が表示されていないことを確認!
expect(screen.queryByText(‘User 1’)).not.toBeInTheDocument();
expect(screen.queryByText(‘user1@example.com’)).not.toBeInTheDocument();
// 唯一表示されているべきは、userId=”2″ の情報だけ
expect(screen.getByText(‘User 2’)).toBeInTheDocument();
expect(screen.getByText(‘user2@example.com’)).toBeInTheDocument();
});
// テストケース3: コンポーネントがアンマウントされた後にデータが来ても無視されるか
test(‘should ignore data fetched after component unmount’, async () => {
const { unmount } = render(
// コンポーネントをアンマウント
unmount();
// 最初のuserId=”1″のデータ取得が完了するのを待つ (500ms)
// このデータは、コンポーネントがアンマウントされた後に届く
await flushPromisesAndDelay(500);
// 画面には何も表示されないはず(または、テストライブラリのデフォルトの表示)
// UserProfile コンポーネント自体はもう DOM にないので、
// 特定のテキストが存在しないことを確認するのは難しいですが、
// エラーが出ないことを確認するだけでも十分な場合もあります。
// ここでは、`act` を使って非同期処理の完了を待つのが一般的です。
await act(async () => {
await flushPromisesAndDelay(500); // データ取得完了まで待つ
});
// 特にエラーが発生せず、テストが成功すればOK
});
});
どうでしょう? `useEffect` のクリーンアップ関数を使うことで、コンポーネントがアンマウントされたり、次の `useEffect` が実行される前に、古い非同期処理の結果を「無視」することができるようになりました。
これで、カフェの店員さんが、間違った飲み物をお客さんに出してしまうような「競合状態」を防ぐことができるわけです。
まとめ:実践的なReact開発のために
- 副作用(Side Effect): UIの表示以外のお仕事(データ取得、タイマーなど)。`useEffect` で管理します。
- 競合状態(Race Condition): 非同期処理が連続して実行される際に、完了順序が入れ替わることで発生する問題。古いデータが表示されるなどのバグの原因になります。
- テストで再現: 非同期処理に意図的に遅延を加えて、完了順序が入れ替わるシナリオをテストでシミュレーションします。
- クリーンアップ関数: `useEffect` の戻り値として定義する関数。コンポーネントのアンマウント時や、次のエフェクト実行前に実行され、古い非同期処理を「キャンセル」したり、「無効」にしたりするのに使います。
- 実践的なクリーンアップ:
- コンポーネントがマウントされているかを示すフラグ (`isComponentMounted`) を使う。
- 現在実行中のリクエストIDを保持し、データが返ってきたときにそのIDと一致するか確認する。
- `AbortController` など、より高度なキャンセル方法もあります。
`useEffect` のクリーンアップ関数は、一見地味に見えるかもしれませんが、Reactアプリケーションの安定性を保つ上で、非常に重要な役割を果たします。特に、API通信などの非同期処理を扱う際には、必ず意識するようにしましょう。
「なんか、ちょっと難しかったかな?」と感じたあなたも、大丈夫!何度もコードを書いて、テストを実行していくうちに、きっと「なるほど!」と思える瞬間が訪れます。
これからも、Reactとの旅を、楽しみながら進んでいきましょうね!

コメント