Part 6 · 副作用 (useEffect)
37 / 64 章
クリーンアップ
後片付けを書かないと何が漏れるのか
useEffect で外側に働きかけたなら、やめるときの後片付けも要ります。
タイマーを動かしたら止める。購読したら解除する (「購読」は「これが起きたら教えて」と登録しておくこと。 新聞の定期購読と同じで、いらなくなったら止める必要があります)。 これを書かないと、画面から消えたあとも動き続けるものが裏に残ります。
返した関数が、後片付けになる
useEffect に渡した関数から関数を返すと、 React はそれを後片付けとして扱います。
useEffect(() => {
const timer = setInterval(tick, 1000);
// これが後片付け
return () => clearInterval(timer);
}, []);返した関数が呼ばれるのは、次の 2 つのときです。
- コンポーネントが画面から消えるとき
- 依存配列の値が変わって、effect をやり直す前
隠すとタイマーも止まり、表示すると 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 を書かなかったとしましょう。 画面から消えても、タイマーは動き続けます。
「隠す」「表示する」を何度か往復してから、数えてみる
動いているタイマー: 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 番だけ、わざと返事が遅くなるようにしてあります
いま選んでいるのは まだ何も選んでいません
ボタンを押すと、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>
);
}