Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Debouncing input in React

Turtlekit Editorial Team

Accountants create invoices in bursts at the end of the month, and the first field they fill in is “Bill to”. They type a customer’s name without looking at the screen, the way anyone types a name they have typed a hundred times. One morning the billing API began refusing some of those searches, and the log showed why: a single invoice had asked for “B”, “Br”, “Bri” and on through all ten letters of “Brightside”.

On a slow connection the same search did something stranger. The list settled on customers starting with “Bri”, while the field said “Brightside”: the answer to the third request had arrived last and replaced the answer to the tenth.

Both come from doing the work on every keystroke. Debouncing waits until the input has been still for a moment, then does the work once, with the value people settled on. Paired with cancelling the request it replaces, it sends one request per pause and shows only the answer to the latest one.

A request for every letter

The search began as the most direct code there is: an effect that runs whenever the query changes.

// Avoid: the effect runs, and the request goes out, on every
// keystroke
useEffect(() => {
  if (!query.trim()) return;
  void searchCustomers(query.trim()).then(setCustomers);
}, [query, searchCustomers]);

Nothing in it is wrong for one keystroke. The effect depends on query, so React runs it after every render where the query changed, which for a typist means every letter. The test further down types “Brightside” into this version and counts ten calls to the API.

The second problem is quieter. The requests are independent, and nothing tells the list which one to believe. Answers come back in whatever order the network delivers them, and the last to arrive wins, which isn’t always the last one asked. react.dev calls this a race condition in its guide to fetching in effects, and fixes it with a cleanup that discards stale answers.

Wait for the pause

A debounce behaves like a lift door. Each person who steps in holds it open a little longer, and it closes only once nobody has stepped in for a moment. Each keystroke restarts the wait, and the work runs when a wait completes.

A request on every keystrokeBrightside10 requestsDebounced: each key restarts the waitBrightside300 ms1 request
Typed at speed, “Brightside” is ten keystrokes. Searching on each sends ten requests; debounced, each keystroke restarts the wait, and one request goes out after the pause.

Debounce the work, never the input. The field shows every letter the moment it is typed; only the request waits. And when a pause ends, the request it starts cancels the one before it, so a late answer has nothing left to replace.

Building the search, one change at a time

Three changes turn the first version into the one that ships: a value that waits, a request that can be cancelled, and a status line that says what the search is doing.

A value that settles

import { useEffect, useState } from "react";

/**
 * The value, once it has stayed the same for `delay` milliseconds.
 * Each new value restarts the wait, so a run of quick changes settles
 * once, at the end.
 */
export function useDebouncedValue<T>(value: T, delay: number): T {
  const [settled, setSettled] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setSettled(value), delay);
    // A newer value, or leaving the page, cancels the wait.
    return () => clearTimeout(timer);
  }, [value, delay]);

  return settled;
}

The hook keeps its own copy of the value and updates it only from a timer. A new value runs the effect again, and React first runs the previous effect’s cleanup, which clears the timer that was waiting. Only a value that lasts the whole delay reaches settled.

Pass it a string or a number. React compares dependencies with Object.is, so an object built during render is new every time, and the timer would restart on every render even with nobody typing.

Pitfall

Debouncing the field’s onChange makes the field itself wait. A controlled input then shows the old value until the timer fires, and letters seem to vanish as people type. Keep the input on every keystroke, and debounce what is computed from it.

Cancelling the request a newer one replaces

const settled = useDebouncedValue(query.trim(), PAUSE_MS);
const [answer, setAnswer] = useState<Answer | null>(null);

useEffect(() => {
  if (!settled) return;
  const controller = new AbortController();
  searchCustomers(settled, { signal: controller.signal }).then(
    (customers) => {
      if (!controller.signal.aborted)
        setAnswer({ query: settled, customers });
    },
    () => {
      if (!controller.signal.aborted)
        setAnswer({ query: settled, failed: true });
    },
  );
  // A newer query, or closing the form, cancels this request.
  return () => controller.abort();
}, [settled, searchCustomers]);

The effect now depends on the settled query rather than the typed one, so it runs once per pause. Each run creates an AbortController and passes its signal to the API, the option fetch takes. When the settled query changes again, or the form closes, the cleanup aborts it: fetch stops the request and rejects. The aborted check covers an API that ignores its signal, so a stale answer is never written either way.

Each answer is stored with the query it answers. That one detail is what lets the status line in the next step be trusted.

Saying what the search is doing

// Derived, never stored: an answer counts only for the query now
// settled.
const current =
  settled && answer?.query === settled ? answer : null;
const customers =
  current && "customers" in current ? current.customers : [];
const found =
  customers.length === 1
    ? "1 customer"
    : `${customers.length} customers`;
const status = !settled
  ? ""
  : !current
    ? "Searching…"
    : "failed" in current
      ? "We couldn’t search customers. Keep typing to try again."
      : customers.length === 0
        ? `No customers match “${settled}”.`
        : `${found} found for “${settled}”.`;

The status is computed during render instead of kept in state, as Deriving state during render recommends: an answer counts only if it belongs to the query now settled. While a request is out, the line says “Searching…”. Between pauses it keeps the last finished search, and names the query it answers, so a count is never read as belonging to the letters typed since.

The line is a status region rendered empty from the start, so screen readers announce each change to it: once when a search starts and once when it ends, rather than once per keystroke. The field names it with aria-describedby, so the latest result is read again when people come back to the field.

The finished search

"use client";
// The "Bill to" search on a new invoice. What people type shows at
// once; the request waits for a pause, and a newer search cancels the
// one before it.
import { useEffect, useId, useState } from "react";
import { useDebouncedValue } from "./use-debounced-value";
import type { Customer, SearchCustomers } from "./customers";

const PAUSE_MS = 300;

/** The API's last answer, and the query it answers. */
type Answer =
  | { query: string; customers: readonly Customer[] }
  | { query: string; failed: true };

interface CustomerSearchProps {
  searchCustomers: SearchCustomers;
  onChoose: (customer: Customer) => void;
}

export function CustomerSearch({
  searchCustomers,
  onChoose,
}: CustomerSearchProps) {
  const id = useId();
  const [query, setQuery] = useState("");
  const settled = useDebouncedValue(query.trim(), PAUSE_MS);
  const [answer, setAnswer] = useState<Answer | null>(null);

  useEffect(() => {
    if (!settled) return;
    const controller = new AbortController();
    searchCustomers(settled, { signal: controller.signal }).then(
      (customers) => {
        if (!controller.signal.aborted)
          setAnswer({ query: settled, customers });
      },
      () => {
        if (!controller.signal.aborted)
          setAnswer({ query: settled, failed: true });
      },
    );
    // A newer query, or closing the form, cancels this request.
    return () => controller.abort();
  }, [settled, searchCustomers]);

  // Derived, never stored: an answer counts only for the query now
  // settled.
  const current =
    settled && answer?.query === settled ? answer : null;
  const customers =
    current && "customers" in current ? current.customers : [];
  const found =
    customers.length === 1
      ? "1 customer"
      : `${customers.length} customers`;
  const status = !settled
    ? ""
    : !current
      ? "Searching…"
      : "failed" in current
        ? "We couldn’t search customers. Keep typing to try again."
        : customers.length === 0
          ? `No customers match “${settled}”.`
          : `${found} found for “${settled}”.`;

  return (
    <search>
      <label htmlFor={`${id}-query`}>Bill to</label>
      <input
        id={`${id}-query`}
        type="search"
        autoComplete="off"
        aria-describedby={`${id}-status`}
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />
      <p id={`${id}-status`} role="status">
        {status}
      </p>
      {customers.length ? (
        <ul aria-label="Matching customers">
          {customers.map((customer) => (
            <li key={customer.id}>
              <button
                type="button"
                onClick={() => onChoose(customer)}
              >
                {customer.name} <span>{customer.email}</span>
              </button>
            </li>
          ))}
        </ul>
      ) : null}
    </search>
  );
}

Type “Brightside” at speed and nothing is sent until you stop. After the pause one request goes out, the line says “Searching…”, then a count of the customers found for “Brightside”, with each customer’s name and email as a button. Pause on another name before that answer arrives, and the request still out is cancelled. If the API fails, the line says so, and the next pause tries again.

What a timer changes in a component

A debounce trades a little latency for much less work, and the trade reaches further than the request count.

Choosing the delay

The delay is a product decision, not a constant to copy. It must be longer than the gap between keystrokes of someone typing steadily, or the search fires mid-word, and short enough that the pause before results still feels like the app answering. The sample uses 300 ms. Tune it with your own users: the API’s logs show how many requests each invoice sends, and the figure should fall to about one per name typed.

Leading, trailing and a maximum wait

This hook is trailing: it acts after the burst ends, which is what a search wants. A leading debounce acts on the first event and ignores the rest of the burst, the shape for a button that must not send a form twice. Some utilities add a maximum wait, so a burst that never pauses, such as dragging a slider, still updates every so often. That is closer to throttling, and suits continuous input better than typing.

Debouncing is not deferring

react.dev’s reference compares the two in how deferring differs from debouncing. A debounce postpones work by a fixed delay; a deferred value renders in the background as soon as the device can, and the next keystroke interrupts it. If the slow part is rendering a list that is already loaded, defer the value. If it is a request, debounce it. Many search screens do both, and useDeferredValue in React covers the first half.

One announcement per search

The field updates on every keystroke, as people expect. What a screen reader says is the status line, which changes when a search starts and when it ends; a result count on every letter would talk over the typing. The results are buttons in a named list, so choosing one works with Tab and Enter. A full combobox, with the arrow keys moving through the options, is what the Turtlekit version below uses.

Testing with fake timers

Fake timers let a test type a whole name in an instant and move the clock only when it chooses:

import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { CustomerSearch } from "./customer-search";
import * as before from "./customer-search-before";
import type { Customer } from "./customers";

const BRIGHTSIDE: Customer = {
  id: "cus_8421",
  name: "Brightside Studio",
  email: "accounts@brightside.studio",
};
const BRISTOW: Customer = {
  id: "cus_3307",
  name: "Bristow & Hale",
  email: "ap@bristowhale.co.uk",
};

beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
afterEach(() => vi.useRealTimers());

const typist = () =>
  userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const pause = (ms: number) =>
  act(() => vi.advanceTimersByTimeAsync(ms));

test("typing a name sends one request, after the pause", async () => {
  const user = typist();
  const searchCustomers = vi.fn(() => Promise.resolve([BRIGHTSIDE]));
  render(
    <CustomerSearch
      searchCustomers={searchCustomers}
      onChoose={() => {}}
    />,
  );
  await user.type(
    screen.getByRole("searchbox", { name: "Bill to" }),
    "Brightside",
  );
  expect(searchCustomers).not.toHaveBeenCalled();
  await pause(300);
  expect(searchCustomers).toHaveBeenCalledTimes(1);
  expect(searchCustomers).toHaveBeenCalledWith("Brightside", {
    signal: expect.any(AbortSignal),
  });
  expect(screen.getByRole("status")).toHaveTextContent(
    "1 customer found for “Brightside”.",
  );
  expect(
    screen.getByRole("button", { name: /Brightside Studio/ }),
  ).toBeVisible();
});

test("searched on every keystroke, one name sends ten", async () => {
  const user = typist();
  const searchCustomers = vi.fn(() => Promise.resolve([BRIGHTSIDE]));
  render(
    <before.CustomerSearch
      searchCustomers={searchCustomers}
      onChoose={() => {}}
    />,
  );
  await user.type(
    screen.getByRole("searchbox", { name: "Bill to" }),
    "Brightside",
  );
  expect(searchCustomers).toHaveBeenCalledTimes(10);
});

test("a late answer never replaces a newer one", async () => {
  const user = typist();
  const pending: {
    query: string;
    signal?: AbortSignal;
    answer: (c: Customer[]) => void;
  }[] = [];
  const searchCustomers = vi.fn(
    (query: string, options?: { signal?: AbortSignal }) =>
      new Promise<readonly Customer[]>((answer) =>
        pending.push({ query, signal: options?.signal, answer }),
      ),
  );
  render(
    <CustomerSearch
      searchCustomers={searchCustomers}
      onChoose={() => {}}
    />,
  );
  const field = screen.getByRole("searchbox", { name: "Bill to" });
  await user.type(field, "Bri");
  await pause(300);
  expect(screen.getByRole("status")).toHaveTextContent("Searching…");
  await user.type(field, "ghtside");
  await pause(300);
  const [older, newer] = pending;
  expect(older?.signal?.aborted).toBe(true);
  await act(async () => newer?.answer([BRIGHTSIDE]));
  await act(async () => older?.answer([BRIGHTSIDE, BRISTOW]));
  expect(screen.getByRole("status")).toHaveTextContent(
    "1 customer found for “Brightside”.",
  );
  expect(
    screen.queryByRole("button", { name: /Bristow/ }),
  ).toBeNull();
});

test("choosing works, and a failure says what to do", async () => {
  const user = typist();
  const onChoose = vi.fn();
  const searchCustomers = vi
    .fn()
    .mockResolvedValueOnce([BRIGHTSIDE])
    .mockRejectedValueOnce(new Error("Offline"));
  render(
    <CustomerSearch
      searchCustomers={searchCustomers}
      onChoose={onChoose}
    />,
  );
  const field = screen.getByRole("searchbox", { name: "Bill to" });
  await user.type(field, "Bright");
  await pause(300);
  await user.click(
    screen.getByRole("button", { name: /Brightside Studio/ }),
  );
  expect(onChoose).toHaveBeenCalledWith(BRIGHTSIDE);
  await user.type(field, "x");
  await pause(300);
  expect(field).toHaveAccessibleDescription(
    "We couldn’t search customers. Keep typing to try again.",
  );
});

Nothing is sent while the name is typed, and one request goes out after the pause. The same typing against the first version counts ten calls. Given two answers in the wrong order, the older request has already been aborted and the newer answer stays on screen.

Server rendering

Effects don’t run on the server, so the server renders the empty search and the browser takes it from there. When a search should survive a reload or be shared, keep the query in the address as a search parameter, and the server can render the first results itself. The debounce then guards the updates to the address as well as the requests.

React 18, 19 and the Compiler

The hook runs unchanged on React 18 and 19. In development, Strict Mode runs each effect, its cleanup and the effect again when a component mounts; with the cleanup in place, the extra run clears its own timer and aborts its own request. The React Compiler memoises values and JSX but doesn’t change when effects run, so it neither replaces the debounce nor gets in its way.

When waiting is the wrong answer

Debounce work that is expensive and whose intermediate results nobody needs. When the data is already in the browser and small, filter it during render with the typed value: there is no request to save, and waiting only makes the list feel slow. When the data is local but large, the cost is rendering, which useDeferredValue handles without a fixed delay. And feedback people watch as they type, such as a character count, should follow every keystroke.

Validation is the subtle case. An error that appears while someone is still typing is unkind whether it arrives at once or after a pause. Check a field when it loses focus, or when the form is sent.

How Turtlekit’s Combobox takes a debounced search

Turtlekit’s Combobox splits the work the same way. With onQueryChange, it hands every keystroke to the page and shows the options it is given without filtering them; with loading, it shows and announces “Searching…” in its list. The page keeps the wait and the cancellation, which are decisions about its own API:

"use client";
// The "Bill to" search with Turtlekit. The page waits for the pause
// and cancels stale requests; Combobox shows and announces
// "Searching…" and the results.
import { useEffect, useState } from "react";
import { Combobox } from "turtlekit";
import { useDebouncedValue } from "./use-debounced-value";
import type { Customer, SearchCustomers } from "./customers";

interface BillToProps {
  searchCustomers: SearchCustomers;
  onCustomerChange: (customerId: string) => void;
}

export function BillTo({
  searchCustomers,
  onCustomerChange,
}: BillToProps) {
  const [query, setQuery] = useState("");
  const settled = useDebouncedValue(query.trim(), 300);
  const [answer, setAnswer] = useState<{
    query: string;
    customers?: readonly Customer[];
  }>();

  useEffect(() => {
    if (!settled) return;
    const controller = new AbortController();
    searchCustomers(settled, { signal: controller.signal }).then(
      (customers) => {
        if (!controller.signal.aborted)
          setAnswer({ query: settled, customers });
      },
      () => {
        if (!controller.signal.aborted) setAnswer({ query: settled });
      },
    );
    return () => controller.abort();
  }, [settled, searchCustomers]);

  const current =
    settled && answer?.query === settled ? answer : undefined;
  const customers = current?.customers ?? [];
  return (
    <Combobox
      label="Bill to"
      name="customer"
      placeholder="Search customers"
      options={customers.map((c) => ({
        value: c.id,
        label: c.name,
        hint: c.email,
      }))}
      loading={Boolean(settled) && !current}
      error={
        current && !current.customers
          ? "We couldn’t search customers. Keep typing to try again."
          : undefined
      }
      emptyText={
        settled ? "No customers match" : "Type a customer’s name"
      }
      onQueryChange={setQuery}
      onValueChange={onCustomerChange}
    />
  );
}

The Combobox brings what the hand-built search left out. The arrow keys move through the options, and the chosen customer becomes the field’s value in the form. A count is announced as the options change, and a failure shows under the field as its error.

When a debounced search misbehaves

It still sends a request on every keystroke

Something restarts or skips the wait. The effect may still depend on the typed query instead of the settled one, or the value passed to the hook is an object rebuilt on each render. With a debounce utility, the debounced function may be created again on every render, each copy with its own timer; create it once, outside the component.

Old results replace new ones

An answer is being applied without checking that it still belongs to the current query. Abort the request in the effect’s cleanup, and check the signal before setting state, for the APIs that ignore it.

A request goes out after the form has closed

The timer outlived the component. The cleanup must clear the timer and abort the request, or a search can still leave after the person has moved on.

Listen, then answer once

A search box is a conversation, and the first version interrupted on every syllable. Waiting for the pause is what a person would do: listen until the other one stops, then answer what they meant rather than what they had said so far. The finished search does that, sending one request per pause and showing only the answer to the latest question. The delay is the part to keep questioning, because the right one belongs to the people typing, and the API’s logs will show when it has drifted.

useDeferredValue in React

When the slow part is rendering a list that is already loaded, not a request.

Deriving state during render

When a status, like this search’s, can be computed instead of stored.

One status instead of many booleans

When a request’s searching, found and failed states should be one value.

Custom hooks and hook composition in React

When the wait and the request belong together in one reusable hook.