本文へスキップ
クリーンアップ

Part 6 · 副作用 (useEffect)

37 / 64

クリーンアップ

後片付けを書かないと何が漏れるのか

useEffect で外側に働きかけたなら、やめるときの後片付けも要ります。

タイマーを動かしたら止める。購読したら解除する (「購読」は「これが起きたら教えて」と登録しておくこと。 新聞の定期購読と同じで、いらなくなったら止める必要があります)。 これを書かないと、画面から消えたあとも動き続けるものが裏に残ります

返した関数が、後片付けになる

useEffect に渡した関数から関数を返すと、 React はそれを後片付けとして扱います。

useEffect(() => {
  const timer = setInterval(tick, 1000);

  // これが後片付け
  return () => clearInterval(timer);
}, []);

返した関数が呼ばれるのは、次の 2 つのときです。

  • コンポーネントが画面から消えるとき
  • 依存配列の値が変わって、effect をやり直す前
表示と連動して動くタイマー

隠すとタイマーも止まり、表示すると 0 から始まる

Clock
表示してから 0

「隠す」を押すと、Clock が画面から消えます。このとき後片付けが呼ばれ、 タイマーが止まります。もう一度表示すると、 新しく作り直されるので 0 から始まります。

timer.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";

function Clock() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds((current) => current + 1);
    }, 1000);

    // 表示をやめるときにタイマーを止める。
    // これを書かないと、消えたあともタイマーだけが動き続ける
    return () => clearInterval(timer);
  }, []);

  // 毎秒描き直されるのはこの部品。1 秒ごとに光る
  return <RenderBox title="Clock">表示してから {seconds} 秒</RenderBox>;
}

export function Timer() {
  const [isVisible, setIsVisible] = useState(true);

  return (
    <div className="flex flex-col gap-4">
      {isVisible ? (
        <Clock />
      ) : (
        <p className="rounded-md border p-3 text-muted-foreground">
          非表示(タイマーは止まっている)
        </p>
      )}

      <Button size="sm" onClick={() => setIsVisible(!isVisible)}>
        {isVisible ? "隠す" : "表示する"}
      </Button>
    </div>
  );
}

書かないと何が起きるか

clearInterval を書かなかったとしましょう。 画面から消えても、タイマーは動き続けます

後片付けを書かなかった時計うまくいかない例

「隠す」「表示する」を何度か往復してから、数えてみる

Clock(後片付けなし)
表示してから 0

動いているタイマー: 0

隠しても、タイマーは止まっていません。 そして往復するたびに新しいタイマーが増えます。 「動いているタイマーを数える」を押すと、いま動いている本数が出ます。

もう一度表示したときに秒数が 0 から始まるので、画面の上では何も壊れていないように見えます。 これがこの不具合のいやなところです。

  • 裏で動き続ける … 見えていない処理が、ずっと CPU を使い続ける
  • 増えていく … 表示・非表示を繰り返すたびにタイマーが増え、止まらないまま溜まる
  • 消えたものを更新しようとする … すでにない state を更新しようとする。 昔は警告が出ましたが、いまは何も言われずに黙って無視されます

この手の問題はすぐには表面化しません。 しばらく使っているうちに重くなる、という形で出てきます。 原因を探すのが非常に大変な種類のバグです。

timer.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";

function Clock() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds((current) => current + 1);
    }, 1000);

    // 表示をやめるときにタイマーを止める。
    // これを書かないと、消えたあともタイマーだけが動き続ける
    return () => clearInterval(timer);
  }, []);

  // 毎秒描き直されるのはこの部品。1 秒ごとに光る
  return <RenderBox title="Clock">表示してから {seconds} 秒</RenderBox>;
}

export function Timer() {
  const [isVisible, setIsVisible] = useState(true);

  return (
    <div className="flex flex-col gap-4">
      {isVisible ? (
        <Clock />
      ) : (
        <p className="rounded-md border p-3 text-muted-foreground">
          非表示(タイマーは止まっている)
        </p>
      )}

      <Button size="sm" onClick={() => setIsVisible(!isVisible)}>
        {isVisible ? "隠す" : "表示する"}
      </Button>
    </div>
  );
}

後片付けは、通信でも要る

後片付けはタイマーだけの話ではありません。通信でこそ効いてきます

こういう画面を考えます。 ボタンで表示する人を切り替えると、その人のデータを取りにいく。 素直に書くと、こうなります。

useEffect(() => {
  fetch(`/api/profile?id=${id}`)
    .then((response) => response.json())
    .then((data) => setProfile(data));
}, [id]);

動いているように見えます。ですが、通信にかかる時間は毎回ちがいます。 先に頼んだほうが先に返ってくるとはかぎりません。

1 番 → 2 番と続けて切り替える

1 番だけ、わざと返事が遅くなるようにしてあります

いま選んでいるのは まだ何も選んでいません

後片付けなし
表示中:
後片付けあり
表示中:

ボタンを押すと、1 番を選んだ直後に 2 番へ切り替わります。 上の箱は、いったん 2 番(すずき)を表示したあと、あとから届いた 1 番(さとう)に上書きされます。 選んでいるのは 2 番なのに、画面には 1 番が出ている状態です。

直し方は、後片付けと同じ

新しい effect が始まる前に、古い effect の結果を無効にしておきます

useEffect(() => {
  // この effect が「まだ有効か」の目印
  let ignore = false;

  fetch(`/api/profile?id=${id}`)
    .then((response) => response.json())
    .then((data) => {
      // 古くなっていたら、返ってきた結果を捨てる
      if (!ignore) setProfile(data);
    });

  return () => {
    ignore = true;
  };
}, [id]);

id が変わると、React はまず後片付けを呼びます。 そこで ignore を立てておけば、 あとから届いた古い返事は捨てられます。 下の箱が上書きされないのは、これが理由です。

race.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";

/* eslint-disable react-hooks/set-state-in-effect */

type Profile = { id: string; name: string; role: string };

/** 後片付けなし。古い返事が、新しい返事を上書きする */
function WithoutCleanup({ id }: { id: string | null }) {
  const [profile, setProfile] = useState<Profile | null>(null);

  useEffect(() => {
    if (!id) return;
    setProfile(null);

    fetch(`/api/profile?id=${id}`)
      .then((response) => response.json())
      .then((data) => setProfile(data));
  }, [id]);

  return (
    <RenderBox title="後片付けなし">
      表示中: {profile ? `${profile.name}(${profile.role})` : "…"}
    </RenderBox>
  );
}

/** 後片付けあり。古い返事は捨てる */
function WithCleanup({ id }: { id: string | null }) {
  const [profile, setProfile] = useState<Profile | null>(null);

  useEffect(() => {
    if (!id) return;
    setProfile(null);

    // この effect が「まだ有効か」の目印
    let ignore = false;

    fetch(`/api/profile?id=${id}`)
      .then((response) => response.json())
      .then((data) => {
        // 古くなっていたら、返ってきた結果を捨てる
        if (!ignore) setProfile(data);
      });

    return () => {
      ignore = true;
    };
  }, [id]);

  return (
    <RenderBox title="後片付けあり" tone="highlight">
      表示中: {profile ? `${profile.name}(${profile.role})` : "…"}
    </RenderBox>
  );
}

export function Race() {
  const [id, setId] = useState<string | null>(null);

  // 1 番を選んだ直後に 2 番へ切り替える。
  // 手で押しても同じだが、確実に再現するようにしてある
  const switchQuickly = () => {
    setId("1");
    setTimeout(() => setId("2"), 200);
  };

  return (
    <div className="flex flex-col gap-4">
      <Button size="sm" onClick={switchQuickly}>
        1 番 → 2 番と続けて切り替える
      </Button>

      <p className="text-sm text-muted-foreground">
        いま選んでいるのは {id ? `${id} 番` : "まだ何も選んでいません"}
      </p>

      <WithoutCleanup id={id} />
      <WithCleanup id={id} />
    </div>
  );
}

よくある後片付け

// タイマー
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

// ブラウザのイベント
useEffect(() => {
  const onScroll = () => { /* ... */ };
  window.addEventListener("scroll", onScroll);
  return () => window.removeEventListener("scroll", onScroll);
}, []);

// 通信(結果が返る前に消えた場合に備える)
// AbortController は「あとから通信を取り消すためのリモコン」。
// signal を fetch に渡しておくと、abort() で取り消せる
useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal });
  return () => controller.abort();
}, [url]);

removeEventListener には登録したものと同じ関数を渡す必要があります。 だから、その場で書かずに変数に入れておきます。

やり直す前にも呼ばれる

依存配列の値が変わったときは、後片付け → 新しい effect の順で実行されます。

useEffect(() => {
  const socket = connect(roomId);
  return () => socket.close();
}, [roomId]);

roomId が変わると、まず古い接続が閉じられ、 そのあと新しい接続が作られます。古いものが残ったまま新しいものが増える、ということが起きません。

timer.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";

function Clock() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds((current) => current + 1);
    }, 1000);

    // 表示をやめるときにタイマーを止める。
    // これを書かないと、消えたあともタイマーだけが動き続ける
    return () => clearInterval(timer);
  }, []);

  // 毎秒描き直されるのはこの部品。1 秒ごとに光る
  return <RenderBox title="Clock">表示してから {seconds} 秒</RenderBox>;
}

export function Timer() {
  const [isVisible, setIsVisible] = useState(true);

  return (
    <div className="flex flex-col gap-4">
      {isVisible ? (
        <Clock />
      ) : (
        <p className="rounded-md border p-3 text-muted-foreground">
          非表示(タイマーは止まっている)
        </p>
      )}

      <Button size="sm" onClick={() => setIsVisible(!isVisible)}>
        {isVisible ? "隠す" : "表示する"}
      </Button>
    </div>
  );
}

理解できたか確かめる

確認クイズ

useEffect から返した関数はいつ呼ばれる?

確認クイズ

clearInterval を書き忘れるとどうなる?

確認クイズ

通信の結果が入れ替わる(古い返事が新しい表示を上書きする)のを防ぐには?

timer.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";

function Clock() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds((current) => current + 1);
    }, 1000);

    // 表示をやめるときにタイマーを止める。
    // これを書かないと、消えたあともタイマーだけが動き続ける
    return () => clearInterval(timer);
  }, []);

  // 毎秒描き直されるのはこの部品。1 秒ごとに光る
  return <RenderBox title="Clock">表示してから {seconds} 秒</RenderBox>;
}

export function Timer() {
  const [isVisible, setIsVisible] = useState(true);

  return (
    <div className="flex flex-col gap-4">
      {isVisible ? (
        <Clock />
      ) : (
        <p className="rounded-md border p-3 text-muted-foreground">
          非表示(タイマーは止まっている)
        </p>
      )}

      <Button size="sm" onClick={() => setIsVisible(!isVisible)}>
        {isVisible ? "隠す" : "表示する"}
      </Button>
    </div>
  );
}

この章のまとめ

  • useEffect から返した関数が後片付けになる
  • 呼ばれるのは、消えるときやり直す前
  • 書かないと、裏で動き続けるものが溜まっていく。すぐには表面化しないのが厄介
  • 始めたら終わらせる。片方を書いた時点でもう片方も書く
  • 開発中に 2 回実行されるのは、後片付けの確認のため

timer.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";

function Clock() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds((current) => current + 1);
    }, 1000);

    // 表示をやめるときにタイマーを止める。
    // これを書かないと、消えたあともタイマーだけが動き続ける
    return () => clearInterval(timer);
  }, []);

  // 毎秒描き直されるのはこの部品。1 秒ごとに光る
  return <RenderBox title="Clock">表示してから {seconds} 秒</RenderBox>;
}

export function Timer() {
  const [isVisible, setIsVisible] = useState(true);

  return (
    <div className="flex flex-col gap-4">
      {isVisible ? (
        <Clock />
      ) : (
        <p className="rounded-md border p-3 text-muted-foreground">
          非表示(タイマーは止まっている)
        </p>
      )}

      <Button size="sm" onClick={() => setIsVisible(!isVisible)}>
        {isVisible ? "隠す" : "表示する"}
      </Button>
    </div>
  );
}