React の useEffect で fetch するコードにたまに出てくる cancelled フラグ。「クリーンアップ関数」という言葉でフリーズしてしまった。AIに身近なたとえ話で説明してもらったので備忘録として残す。
この記事は誰向け?
- React を触り始めたばかりの人
-
useEffectの中で fetch するコードを見て「cancelledってなに?」と思った人 - 「クリーンアップ関数」という言葉でフリーズしてしまう人
難しい用語はいったん忘れて、身近なたとえ話から入っていく。
まず結論:何の話をしているのか
React で「データを取ってきて画面に表示する」を書くと、こういうコードに出会う。
useEffect(() => {
let cancelled = false;
(async () => {
const result = await getStaffsApi(officeId);
if (cancelled) return;
setStaffList(result.data);
})();
return () => {
cancelled = true;
};
}, [officeId]);
このコードが何をしているか、ひとことで言うと:
「古い注文の料理が後から届いても、食べずに捨てる」仕組み
…なんのこと?と思うはず。順番に見ていく。
たとえ話:ラーメン屋さんで注文を変えた話
あなたはラーメン屋さんに来た。
シーン1: 普通に注文する
- 「醤油ラーメンください」と注文する(= fetch 開始)
- 数分後、醤油ラーメンが届く(= fetch 完了)
- 食べる(=
setStaffList(result.data)実行)
これは普通のケース。何も問題ない。
シーン2: 気が変わった
- 「醤油ラーメンください」と注文する(= 1回目の fetch 開始)
- やっぱり気が変わって「やっぱり味噌で!」と言う(=
officeIdが変わる、2回目の fetch 開始) - しばらくして…醤油と味噌、どっちも届いてしまう
ここで問題が起きる。先に届くのが醤油とは限らないのだ。
店が混んでいたら、後から注文した味噌のほうが先に出てくることもある。
注文① 醤油 → ・・・(調理に時間がかかる)・・・ → 完成
注文② 味噌 → ・サッと完成
このとき、何も対策しないとこうなる。
- 味噌が届く → 食べる(画面に表示)
- その後、醤油が届く → これも食べてしまう(画面が醤油に上書きされる!)
最終的に画面は 醤油。でもユーザーが最後に選んだのは 味噌。バグである。
解決策:「注文ごとに札をつける」
そこで、こんなルールにする。
注文ごとに札を 1 枚つける。最初は「有効」と書いてある。
新しい注文をするときに、前の注文の札に「キャンセル」と書き換える。
料理ができあがったら、その注文の札を確認して、「キャンセル」になっていたら食べずに捨てる。
ポイントは、札は注文ごとに別物で、料理が届いたときは「その料理に紐づいた札」だけを見る、ということ。これがコードでいう cancelled フラグになる。
let cancelled = false; // ← この注文の札(最初は「有効」)
(async () => {
const result = await getStaffsApi(officeId); // ← 料理を待つ
if (cancelled) return; // ← 札を見る。キャンセルされてたら食べない
setStaffList(result.data); // ← セーフ。食べる(画面に表示)
})();
return () => {
cancelled = true; // ← 次の注文が来たら、前の札に「キャンセル」と書く
};
「札が上書きされちゃわない?」という疑問
「let cancelled = false って毎回書いてあるけど、新しい注文するたびに false に戻っちゃうのでは?」
これは 戻らない。なぜなら…
イメージ:札は注文ごとに「別の紙」
useEffect が動くたびに、まっさらな新しい札が用意される。
名前は同じ「cancelled」だけど、物理的に別の紙だ。
1回目の注文 → 札A(cancelled = false と書いてある)
2回目の注文 → 札B(cancelled = false と書いてある)← Aとは別物!
2回目の注文が始まるとき、React は「1回目の札Aに『キャンセル』と書いてね」と頼む(これが return () => { cancelled = true } の役割)。
このとき書き換えられるのは 札A だけ。札B は無傷である。
だから:
- 1回目の料理(醤油)が後から届く → 札Aを見る → 「キャンセル」 → 捨てる ✓
- 2回目の料理(味噌)が届く → 札Bを見る → 「有効」 → 食べる ✓
うまくいく。
実はこの「札が別物として残る」仕組みは、JavaScript のクロージャによるもの。Effect コールバックが実行されるたびに、独立したスコープと変数が新しく作られている。
return () => { ... } ってなに?
React を学び始めると、useEffect の中で「return で関数を返す」という不思議なコードに出会う。
useEffect(() => {
// 何か処理
return () => {
// ← この関数、いつ動くの?
};
}, []);
これは、「あとで必要なときに呼んでね」と React に関数を預けているだけ。
書いた瞬間に動くわけではない。
配達員にたとえると
宅配ピザを頼むとき、注文と一緒に「もし配達中にキャンセルしたくなったら、この番号にかけてください」と電話番号を渡される、みたいなものだ。
- ピザを注文する(= Effect 実行)
- キャンセル用の電話番号を受け取る(=
return () => {...}を React が預かる) - もし気が変わったら、その番号に電話する(= React がクリーンアップ関数を呼ぶ)
React がクリーンアップ関数を呼ぶタイミング
- 次の Effect が実行される直前(= 次の注文をする前に、前の注文をキャンセル)
- コンポーネントが画面から消えるとき(= お店から出ていくときに、まだ届いてない注文をキャンセル)
もう少しちゃんとした流れ図
officeId が変わったときに何が起きるか、順番を追ってみる。
[1] officeId = "東京" で Effect が動く
↓
[2] cancelled_東京 = false (札Aを用意)
↓
[3] fetch開始(東京のスタッフ一覧を取りに行く)
↓
[4] React に「キャンセル関数」を預ける
↓
[5] ユーザーが officeId を "大阪" に変更
↓
[6] React「次の Effect 動かす前に、前のキャンセル関数呼ぼう」
→ cancelled_東京 = true (札Aに「キャンセル」と書く)
↓
[7] 新しい Effect が動く
↓
[8] cancelled_大阪 = false (別の札Bを用意)
↓
[9] fetch開始(大阪のスタッフ一覧)
↓
[10] 東京のfetchがやっと完了
→ if (cancelled_東京) → true → return(捨てる)✓
↓
[11] 大阪のfetchも完了
→ if (cancelled_大阪) → false → 画面に大阪を表示 ✓
最終的に画面に出るのは 大阪。正しい結果。
どんなときに役立つ?(具体例)
例1: 検索ボックス
ユーザーが検索ボックスに「ね」「ねこ」「ねこか」「ねこかわ」と素早く入力するたびに API を叩くケース。
クリーンアップを書いていないと:
- 「ね」の検索結果(猫、ねぎ、ネジ…)
- 「ねこ」の検索結果(猫、ネコ科…)
- 「ねこか」の検索結果
- 「ねこかわ」の検索結果(ねこかわいい…)
これがバラバラの順番で返ってくると、最後に画面に出るのが「ね」の結果になることがある。
ユーザーは「ねこかわ」と打ったのに「ねぎ」が出てくる…という意味不明な状態になってしまう。
cancelled フラグを使えば、古い検索結果は無視される。
例2: ページ切り替え
ユーザー一覧ページから商品一覧ページに切り替えた瞬間、ユーザー一覧の fetch がまだ完了していないとき。
クリーンアップがないと、商品ページを開いているのに「ユーザー一覧」のデータが後から画面に反映されてしまうことがある。
まとめ:覚えてほしい3つのこと
1. let cancelled = false は毎回「新しい紙」
上書きされない。Effect が動くたびに、別の場所に新しく作られる(クロージャの仕組み)。
2. return () => {...} は「あとで呼んでね」と渡してるだけ
書いた瞬間には動かない。React が必要なときに呼んでくれる。
3. このパターンは「古い注文を捨てる」ためにある
非同期処理は、頼んだ順番どおりに返ってくるとは限らない。
だから「これはもう古い結果だよ」と教える仕組みが必要になる。
※ この記事は Claude Code にまとめてもらっています。