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.
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.