本文へスキップ
入力を間引く

Part 10 · 実務で使う道具

57 / 64

入力を間引く

打つたびに走らせない。debounce と throttle

打ちながら検索結果が出てくる画面。よくあります。

素直に作ると、1 文字打つたびにサーバーへ問い合わせることになります。 「reactjs」と打てば 7 回です。

欲しかったのは最後の 1 回だけでした。

なおこの章は、この Part のほかの章と少し毛色が違います。新しい道具というより、Part 6 でやったことの応用です。 使うライブラリも、中身は数行しかありません。

回数を数えてみる

keyword が変わるたびに検索する、 という素直な書き方です。

打つたびに検索するうまくいかない例

「reactjs」と打つと、1 文字ごとに箱が光る

打つたびに検索
検索した回数: 0

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 か所だけです。

落ち着いてから検索する直した例

打っている間は光らない。手を止めると光る

入力欄: (空) / 検索に使う値: (空)

落ち着いてから検索
検索した回数: 0

打っている間、箱は光りません。手を止めて 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>
  );
}