Part 10 · 実務で使う道具
57 / 64 章
入力を間引く
打つたびに走らせない。debounce と throttle
打ちながら検索結果が出てくる画面。よくあります。
素直に作ると、1 文字打つたびにサーバーへ問い合わせることになります。 「reactjs」と打てば 7 回です。
欲しかったのは最後の 1 回だけでした。
なおこの章は、この Part のほかの章と少し毛色が違います。新しい道具というより、Part 6 でやったことの応用です。 使うライブラリも、中身は数行しかありません。
回数を数えてみる
keyword が変わるたびに検索する、 という素直な書き方です。
「reactjs」と打つと、1 文字ごとに箱が光る
7 文字打つと、7 回になります。実際のアプリでは、これが 7 回の API 呼び出しです。
問題は 2 つあります。
- むだが多い … 途中の「rea」「reac」の結果は誰も見ていません
- 順番が入れ替わる … 「rea」の結果が「reactjs」より後に届くと、古い結果で上書きされます
2 つめのほうが厄介です。 たまにしか起きず、再現しにくく、「たまに検索結果がおかしい」という形で報告されます。
eager.tsx
"use client";
import { RenderBox } from "@/components/lesson/render-box";
import { Input } from "@/components/ui/input";
import { memo, useEffect, useState } from "react";
/*
本来ここは「API を呼んで、返ってきた結果を state に入れる」処理。
デモでは通信の代わりに回数だけ数えている。
lint は「effect の中で直接 setState するな」と止めてくるが、
実物では await を挟むので、この形自体は現実のコードに近い。
*/
/* eslint-disable react-hooks/set-state-in-effect */
// 検索の回数が変わったときだけ描き直される
const SearchBox = memo(function SearchBox({ count }: { count: number }) {
return (
<RenderBox title="打つたびに検索">
検索した回数: <strong>{count}</strong>
</RenderBox>
);
});
export function Eager() {
const [keyword, setKeyword] = useState("");
const [searchCount, setSearchCount] = useState(0);
useEffect(() => {
if (!keyword) return;
// 本来はここで API を呼ぶ。回数だけ数えている
setSearchCount((count) => count + 1);
}, [keyword]);
return (
<div className="flex flex-col gap-3">
<Input
placeholder="検索してみる"
aria-label="検索してみる"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
{/* 検索が走ったときだけ光らせたいので、打鍵の巻き添えを memo で切る */}
<SearchBox count={searchCount} />
</div>
);
}落ち着くまで待つ
やりたいのは「打ち終わってから検索する」です。 ですが「打ち終わった」という出来事はありません。
そこで、こう考えます。「一定時間、何も打たれなければ、打ち終わったとみなす」。 これを debounce と呼びます。
const [debouncedKeyword] = useDebounce(keyword, 500);keyword はいつもどおり、打つたびに変わります。debouncedKeyword のほうは、500ms 止まってから追いつきます。
debounced.tsx
"use client";
import { RenderBox } from "@/components/lesson/render-box";
import { Input } from "@/components/ui/input";
import { memo, useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
/*
本来ここは「API を呼んで、返ってきた結果を state に入れる」処理。
デモでは通信の代わりに回数だけ数えている。
lint は「effect の中で直接 setState するな」と止めてくるが、
実物では await を挟むので、この形自体は現実のコードに近い。
*/
/* eslint-disable react-hooks/set-state-in-effect */
// 検索の回数が変わったときだけ描き直される
const SearchBox = memo(function SearchBox({ count }: { count: number }) {
return (
<RenderBox title="落ち着いてから検索">
検索した回数: <strong>{count}</strong>
</RenderBox>
);
});
export function Debounced() {
const [keyword, setKeyword] = useState("");
// 打つのが 500ms 止まってから、こちらの値が追いつく
const [debouncedKeyword] = useDebounce(keyword, 500);
const [searchCount, setSearchCount] = useState(0);
useEffect(() => {
if (!debouncedKeyword) return;
// 見張るのは「落ち着いたほうの値」
setSearchCount((count) => count + 1);
}, [debouncedKeyword]);
return (
<div className="flex flex-col gap-3">
<Input
placeholder="検索してみる"
aria-label="検索してみる"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<p className="text-sm text-muted-foreground">
入力欄: {keyword || "(空)"} / 検索に使う値:{" "}
{debouncedKeyword || "(空)"}
</p>
{/* 検索が走ったときだけ光らせたいので、打鍵の巻き添えを memo で切る */}
<SearchBox count={searchCount} />
</div>
);
}見張る先を変えるだけ
// 変えたのは、依存配列に書く値だけ
useEffect(() => {
search(debouncedKeyword);
}, [debouncedKeyword]);keyword ではなく debouncedKeyword を見張る。変更はこの 1 か所だけです。
打っている間は光らない。手を止めると光る
入力欄: (空) / 検索に使う値: (空)
打っている間、箱は光りません。手を止めて 500ms たつと、そこで一度だけ光ります。 入力欄の表示は遅れていないのに、検索だけが遅れている—— それがそのまま目に見えます。
2 つの値がずれる様子も出してあります。 打っている間は「検索に使う値」が遅れて追いかけてくるのが見えます。
debounced.tsx
"use client";
import { RenderBox } from "@/components/lesson/render-box";
import { Input } from "@/components/ui/input";
import { memo, useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
/*
本来ここは「API を呼んで、返ってきた結果を state に入れる」処理。
デモでは通信の代わりに回数だけ数えている。
lint は「effect の中で直接 setState するな」と止めてくるが、
実物では await を挟むので、この形自体は現実のコードに近い。
*/
/* eslint-disable react-hooks/set-state-in-effect */
// 検索の回数が変わったときだけ描き直される
const SearchBox = memo(function SearchBox({ count }: { count: number }) {
return (
<RenderBox title="落ち着いてから検索">
検索した回数: <strong>{count}</strong>
</RenderBox>
);
});
export function Debounced() {
const [keyword, setKeyword] = useState("");
// 打つのが 500ms 止まってから、こちらの値が追いつく
const [debouncedKeyword] = useDebounce(keyword, 500);
const [searchCount, setSearchCount] = useState(0);
useEffect(() => {
if (!debouncedKeyword) return;
// 見張るのは「落ち着いたほうの値」
setSearchCount((count) => count + 1);
}, [debouncedKeyword]);
return (
<div className="flex flex-col gap-3">
<Input
placeholder="検索してみる"
aria-label="検索してみる"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<p className="text-sm text-muted-foreground">
入力欄: {keyword || "(空)"} / 検索に使う値:{" "}
{debouncedKeyword || "(空)"}
</p>
{/* 検索が走ったときだけ光らせたいので、打鍵の巻き添えを memo で切る */}
<SearchBox count={searchCount} />
</div>
);
}中で何が起きているか
useDebounce も、Part 6 で作ったのと同じカスタムフックです。中身はこういう形です。
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
// 次の入力が来たら、前回の予約を取り消す
return () => clearTimeout(timer);
}, [value, delay]);Part 6 のクリーンアップそのものです。打つたびに前回のタイマーを取り消して、新しく予約し直す。 取り消されずに残ったものだけが実行されます。
debounced.tsx
"use client";
import { RenderBox } from "@/components/lesson/render-box";
import { Input } from "@/components/ui/input";
import { memo, useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
/*
本来ここは「API を呼んで、返ってきた結果を state に入れる」処理。
デモでは通信の代わりに回数だけ数えている。
lint は「effect の中で直接 setState するな」と止めてくるが、
実物では await を挟むので、この形自体は現実のコードに近い。
*/
/* eslint-disable react-hooks/set-state-in-effect */
// 検索の回数が変わったときだけ描き直される
const SearchBox = memo(function SearchBox({ count }: { count: number }) {
return (
<RenderBox title="落ち着いてから検索">
検索した回数: <strong>{count}</strong>
</RenderBox>
);
});
export function Debounced() {
const [keyword, setKeyword] = useState("");
// 打つのが 500ms 止まってから、こちらの値が追いつく
const [debouncedKeyword] = useDebounce(keyword, 500);
const [searchCount, setSearchCount] = useState(0);
useEffect(() => {
if (!debouncedKeyword) return;
// 見張るのは「落ち着いたほうの値」
setSearchCount((count) => count + 1);
}, [debouncedKeyword]);
return (
<div className="flex flex-col gap-3">
<Input
placeholder="検索してみる"
aria-label="検索してみる"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<p className="text-sm text-muted-foreground">
入力欄: {keyword || "(空)"} / 検索に使う値:{" "}
{debouncedKeyword || "(空)"}
</p>
{/* 検索が走ったときだけ光らせたいので、打鍵の巻き添えを memo で切る */}
<SearchBox count={searchCount} />
</div>
);
}理解できたか確かめる
確認クイズ
debounce で入力欄の表示が遅れないのはなぜ?
確認クイズ
debounce で解決するのはどちら?
確認クイズ
スクロール位置を追いかけるのに向いているのは?
debounced.tsx
"use client";
import { RenderBox } from "@/components/lesson/render-box";
import { Input } from "@/components/ui/input";
import { memo, useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
/*
本来ここは「API を呼んで、返ってきた結果を state に入れる」処理。
デモでは通信の代わりに回数だけ数えている。
lint は「effect の中で直接 setState するな」と止めてくるが、
実物では await を挟むので、この形自体は現実のコードに近い。
*/
/* eslint-disable react-hooks/set-state-in-effect */
// 検索の回数が変わったときだけ描き直される
const SearchBox = memo(function SearchBox({ count }: { count: number }) {
return (
<RenderBox title="落ち着いてから検索">
検索した回数: <strong>{count}</strong>
</RenderBox>
);
});
export function Debounced() {
const [keyword, setKeyword] = useState("");
// 打つのが 500ms 止まってから、こちらの値が追いつく
const [debouncedKeyword] = useDebounce(keyword, 500);
const [searchCount, setSearchCount] = useState(0);
useEffect(() => {
if (!debouncedKeyword) return;
// 見張るのは「落ち着いたほうの値」
setSearchCount((count) => count + 1);
}, [debouncedKeyword]);
return (
<div className="flex flex-col gap-3">
<Input
placeholder="検索してみる"
aria-label="検索してみる"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<p className="text-sm text-muted-foreground">
入力欄: {keyword || "(空)"} / 検索に使う値:{" "}
{debouncedKeyword || "(空)"}
</p>
{/* 検索が走ったときだけ光らせたいので、打鍵の巻き添えを memo で切る */}
<SearchBox count={searchCount} />
</div>
);
}この章のまとめ
- 1 文字ごとの処理はむだが多い。 (順序の入れ替わりは別問題で、これは debounce では解けない)
- debounce は「一定時間止まったら、打ち終わったとみなす」
- 表示用の値と処理用の値を分ける。 見た目は速いまま
- 中身は setTimeout + クリーンアップ。 Part 6 でやったこと
- 止まらないものを追うなら throttle
debounced.tsx
"use client";
import { RenderBox } from "@/components/lesson/render-box";
import { Input } from "@/components/ui/input";
import { memo, useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
/*
本来ここは「API を呼んで、返ってきた結果を state に入れる」処理。
デモでは通信の代わりに回数だけ数えている。
lint は「effect の中で直接 setState するな」と止めてくるが、
実物では await を挟むので、この形自体は現実のコードに近い。
*/
/* eslint-disable react-hooks/set-state-in-effect */
// 検索の回数が変わったときだけ描き直される
const SearchBox = memo(function SearchBox({ count }: { count: number }) {
return (
<RenderBox title="落ち着いてから検索">
検索した回数: <strong>{count}</strong>
</RenderBox>
);
});
export function Debounced() {
const [keyword, setKeyword] = useState("");
// 打つのが 500ms 止まってから、こちらの値が追いつく
const [debouncedKeyword] = useDebounce(keyword, 500);
const [searchCount, setSearchCount] = useState(0);
useEffect(() => {
if (!debouncedKeyword) return;
// 見張るのは「落ち着いたほうの値」
setSearchCount((count) => count + 1);
}, [debouncedKeyword]);
return (
<div className="flex flex-col gap-3">
<Input
placeholder="検索してみる"
aria-label="検索してみる"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<p className="text-sm text-muted-foreground">
入力欄: {keyword || "(空)"} / 検索に使う値:{" "}
{debouncedKeyword || "(空)"}
</p>
{/* 検索が走ったときだけ光らせたいので、打鍵の巻き添えを memo で切る */}
<SearchBox count={searchCount} />
</div>
);
}