1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React】useEffect のクリーンアップを「ラーメン屋の注文」でたとえてみる

1
Posted at

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: 普通に注文する

  1. 「醤油ラーメンください」と注文する(= fetch 開始)
  2. 数分後、醤油ラーメンが届く(= fetch 完了)
  3. 食べる(= setStaffList(result.data) 実行)

これは普通のケース。何も問題ない。

シーン2: 気が変わった

  1. 「醤油ラーメンください」と注文する(= 1回目の fetch 開始)
  2. やっぱり気が変わって「やっぱり味噌で!」と言う(= officeId が変わる、2回目の fetch 開始)
  3. しばらくして…醤油と味噌、どっちも届いてしまう

ここで問題が起きる。先に届くのが醤油とは限らないのだ。
店が混んでいたら、後から注文した味噌のほうが先に出てくることもある。

注文① 醤油  →  ・・・(調理に時間がかかる)・・・  →  完成
注文② 味噌  →  ・サッと完成

このとき、何も対策しないとこうなる。

  1. 味噌が届く → 食べる(画面に表示)
  2. その後、醤油が届く → これも食べてしまう(画面が醤油に上書きされる!)

最終的に画面は 醤油。でもユーザーが最後に選んだのは 味噌。バグである。


解決策:「注文ごとに札をつける」

そこで、こんなルールにする。

注文ごとに札を 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 にまとめてもらっています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?