クリーンアップ

Part 6 · 副作用 (useEffect)

クリーンアップ

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

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

タイマーを動かしたら止める。購読したら解除する。 これを書かないと、画面から消えたあとも動き続けるものが裏に残ります

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

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

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

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

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

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

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

表示してから 0

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

timer.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
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);
  }, []);

  return (
    <p className="rounded-md border p-3 font-mono">
      表示してから {seconds} 秒
    </p>
  );
}

export function Timer() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  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 を書かなかったとしましょう。 画面から消えても、タイマーは動き続けます

  • 裏で動き続ける … 見えていない処理が、ずっと CPU を使い続ける
  • 増えていく … 表示・非表示を繰り返すたびにタイマーが増え、止まらないまま溜まる
  • 消えたものを更新しようとする … すでにない state を更新しようとして、警告やエラーになる

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

timer.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
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);
  }, []);

  return (
    <p className="rounded-md border p-3 font-mono">
      表示してから {seconds} 秒
    </p>
  );
}

export function Timer() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  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(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

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

// 通信(結果が返る前に消えた場合に備える)
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 { useTrackDemoRender } from "@/components/lesson/demo-card";
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);
  }, []);

  return (
    <p className="rounded-md border p-3 font-mono">
      表示してから {seconds} 秒
    </p>
  );
}

export function Timer() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  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 { useTrackDemoRender } from "@/components/lesson/demo-card";
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);
  }, []);

  return (
    <p className="rounded-md border p-3 font-mono">
      表示してから {seconds} 秒
    </p>
  );
}

export function Timer() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  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 { useTrackDemoRender } from "@/components/lesson/demo-card";
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);
  }, []);

  return (
    <p className="rounded-md border p-3 font-mono">
      表示してから {seconds} 秒
    </p>
  );
}

export function Timer() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  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>
  );
}