「このコード、並列リクエストが両方通ってしまうリスクがあります」とコードレビューで指摘を受けました。
「レースコンディション」という概念を理解できていなかった自分が、指摘を受けて理解するまでの話を書きます。
対象読者Reactの非同期処理・useEffectでのデータ取得・レースコンディションが何かわからないエンジニア向けです。
- レースコンディションとは何か
- 指摘を受けたコードの問題点
- なぜ「動いているのに問題か」
- 解決策:useRefでガードする
- まとめ:「動く」と「正しい」は違う
レースコンディションとは何か
レースコンディション(Race Condition)とは、複数の処理が同時に走って、順番によって結果が変わってしまう問題です。
わかりやすい例で説明します。
# レースコンディションのイメージ # 状況:画面を開くと transcript と summary を並列で取得する 1. useEffectが実行される 2. loadTranscript() が実行される(処理A) 3. loadSummary() が実行される(処理B) # 問題: # 処理Aと処理Bが両方「loadedRef.current = false」の状態で # 同時に走ってしまうと、ガード処理が機能しない # 期待した動き: # 最初のリクエストが完了したら、2回目はスキップする # 実際の動き(問題あり): # 両方のリクエストが先に走り始めてから、後からガードをセットする # → 両方のリクエストが通ってしまう
指摘を受けたコードの問題点
// ❌ 問題のあったコード
const loadedTranscriptPathRef = useRef<string | null>(null);
const loadTranscript = async () => {
// この時点でloadedTranscriptPathRef.currentはnull
// → 2つのリクエストが同時に走るとここを両方通過してしまう
if (loadedTranscriptPathRef.current === transcriptPath) return;
const result = await fetchTranscript(transcriptPath);
// fetchが完了してからrefをセット
// → 両方のリクエストが fetchTranscript を実行してしまう
loadedTranscriptPathRef.current = transcriptPath;
setTranscript(result);
};
// useEffectで呼ばれる
useEffect(() => {
loadTranscript(); // 1回目
loadTranscript(); // 2回目(条件次第で呼ばれる)
}, [transcriptPath]);
問題の核心は「loadedTranscriptPathRef.currentがセットされるのはfetchTranscriptが完了した後」という点です。両方のリクエストがfetchTranscriptを実行し始めてから、後からガードをセットしても手遅れです。
⚠️ なぜ「普段は気づかない」か
ほとんどの場合、この問題は顕在化しません。でも「ネットワークが遅い」「重い処理と同時に走る」「特定のタイミングでレンダリングが走る」という条件が重なると、二重リクエストが発生してデータが壊れたりエラーになります。なぜ「動いているのに問題か」
指摘を受けたとき最初は「でも普通に動いてるし…」と思いました。でもレビュアーの説明を聞いて理解しました。
「普段は動く」と「必ず正しく動く」は違います。
普段は問題ないのは「タイミングがたまたまずれていないから」です。ネットワーク遅延・CPUの負荷・他の処理との競合など、さまざまな要因でタイミングが変わったとき、初めてバグとして現れます。本番環境・ユーザーの端末・特定の条件下では再現する可能性があります。
解決策:useRefでガードする
// ✅ 修正後のコード
const loadedTranscriptPathRef = useRef<string | null>(null);
const loadTranscript = async () => {
// fetchを実行する前にガードをセットする(順番が重要)
if (loadedTranscriptPathRef.current === transcriptPath) return;
// 先にrefをセット → 2回目のリクエストはここでreturnされる
loadedTranscriptPathRef.current = transcriptPath;
const result = await fetchTranscript(transcriptPath);
setTranscript(result);
};
修正のポイントは「fetchTranscriptを呼ぶ前にloadedTranscriptPathRef.currentをセットする」ことです。こうすることで、2回目のリクエストが来たときにすでにrefがセットされているため、確実にスキップされます。
? useRefを使う理由
stateではなくrefを使うのは、refの変更はレンダリングを引き起こさないためです。ガード用のフラグをstateで管理すると、セットするたびに再レンダリングが走ってしまいます。「レンダリングに関係ない値の保持」にはrefが適しています。- レースコンディションとは複数の処理が同時に走って結果が変わる問題
- 「普段は動く」と「必ず正しく動く」は違う。本番の特定条件下でバグになる可能性がある
- 解決策:非同期処理の前にガードをセットする(fetchの後ではなく前)
- ガード用フラグはstateではなくuseRefを使う(不要なレンダリングを防ぐため)
コードレビューで「動いているのに指摘された」経験は、「正しさ」の基準を上げてくれます。「動く」から「正しく動く」へのレベルアップは、レビューを通じてしか学べないことだと思っています。


コメント