At the end of the year, the invoices page holds every invoice the business sent, all of them loaded with the page so that searching feels instant. For most of the year it did. In the last week of December, on the finance manager’s older laptop, the search box began to lag: letters appeared in clumps, a beat after they were typed.
The invoices were already in the browser, so there was no request to wait for. The time went into rendering. Each keystroke filtered the year’s invoices and rendered the matching rows, and the browser couldn’t paint the new letter in the field until that render had finished.
useDeferredValue separates the two. It gives the table a copy of the query that is allowed to lag behind. React renders the field with the new letter first, then the table with the new query in the background, and abandons that render if another letter arrives.
One keystroke, one long render
Here is the page. The query goes straight from the field to the table:
// Avoid: the input can't show a letter until the table has finished
export function InvoiceSearch({
invoices,
}: {
invoices: readonly Invoice[];
}) {
const [query, setQuery] = useState("");
return (
<section aria-label="Invoices">
<label htmlFor="invoice-query">Search invoices</label>
<input
id="invoice-query"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<InvoiceTable invoices={invoices} query={query} />
</section>
);
}And the table, which filters the invoices and renders a row for each match:
// The invoices that match a query, as a table: thousands of rows at
// the end of the year, and the slow part of the page.
import { filterInvoices } from "./invoices";
import type { Invoice } from "./invoices";
interface InvoiceTableProps {
invoices: readonly Invoice[];
query: string;
}
export function InvoiceTable({ invoices, query }: InvoiceTableProps) {
const found = filterInvoices(invoices, query);
const matches =
found.length === 1
? "1 invoice matches"
: `${found.length} invoices match`;
return (
<>
{/* Announced as the table settles: empty, then filled. */}
<p role="status">{query.trim() ? `${matches}.` : ""}</p>
<table>
<caption>
{found.length} of {invoices.length} invoices
</caption>
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col">Client</th>
<th scope="col">Issued</th>
<th scope="col">Total</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{found.map((invoice) => (
<tr key={invoice.id}>
<th scope="row">{invoice.id}</th>
<td>{invoice.client}</td>
<td>{invoice.issued}</td>
<td>{invoice.total}</td>
<td>{invoice.status}</td>
</tr>
))}
</tbody>
</table>
</>
);
}Every keystroke is one render of the section, field and table together. An update caused by typing is urgent, and React finishes an urgent render before the browser paints, so the field can’t show the letter until the table is done. On a fast machine the table takes too little time to notice. On a slow one it takes longer than the gap between keystrokes, and the typing queues up behind it.
Debouncing the query would render less often, but each render would still block when it came, and a fast typist would see no results until they paused. react.dev’s reference makes the same point: a debounce only postpones the moment rendering blocks the keystroke.
Two renders, and one can be thrown away
Think of the table as a draft. The field is urgent: it must show what people typed, now. The table may be a moment behind, and if a newer query arrives while React is drawing it, the half-drawn version is thrown away and React starts on the new one.
Trace one keystroke with real values. The field holds “b”, and someone types “r”:
In the first render, query is “br” and deferredQuery is still “b”. The table receives the same query as last time, so it can skip rendering, and the field paints at once. In the second, React renders again with deferredQuery as “br”. That render runs in the background: a keystroke during it interrupts it, and React starts again from the latest value.
Urgent work first; the rest when there is time, and only for the latest value. There is no delay to choose, either. As react.dev’s reference for useDeferredValue notes, the background render starts as soon as the first one finishes, so on a fast device the table catches up before anyone sees it lag.
Deferring the query, step by step
Three changes make the page responsive: the deferred value, a memoised table, and a sign that the results are catching up.
Defer the value the table reads
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const stale = query !== deferredQuery;The field keeps query, so it never waits. The table gets deferredQuery. stale is true between the two renders, while the field is ahead of the table.
Let the table skip the urgent render
// The table skips the urgent render, where its query hasn't changed
// yet.
const InvoiceResults = memo(InvoiceTable);memo is what makes the first render cheap. It compares the table’s props with the last render’s, and in the urgent render they are equal, so React reuses the table as it was.
Pitfall
Without memo, deferring makes the page slower. The table renders in the urgent pass anyway, with its old query, then again in the background with the new one: twice the work for each letter. The test below measures it.
Show that the results are catching up
While stale is true, the table still shows the results for the previous query. That is better than a spinner, which would hide rows people may be reading, but they should know the rows are about to change. The page dims them after a short delay, so a fast device never flickers, and marks the region aria-busy. The table’s own status line, rendered empty and filled only when the table settles, announces how many invoices match.
The finished page
"use client";
// The invoice search: the input renders first with every keystroke,
// and the table catches up in a render React can interrupt.
import { memo, useDeferredValue, useId, useState } from "react";
import { InvoiceTable } from "./invoice-table";
import type { Invoice } from "./invoices";
// The table skips the urgent render, where its query hasn't changed
// yet.
const InvoiceResults = memo(InvoiceTable);
export function InvoiceSearch({
invoices,
}: {
invoices: readonly Invoice[];
}) {
const id = useId();
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const stale = query !== deferredQuery;
return (
<section aria-labelledby={`${id}-title`}>
<h2 id={`${id}-title`}>Invoices</h2>
<label htmlFor={`${id}-query`}>Search invoices</label>
<input
id={`${id}-query`}
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div
aria-busy={stale}
style={{
opacity: stale ? 0.72 : 1,
// Dim only after a short wait, so a fast device never
// flickers.
transition: stale ? "opacity 0.2s 0.2s" : "none",
}}
>
<InvoiceResults invoices={invoices} query={deferredQuery} />
</div>
</section>
);
}Type “br” on a slow machine and both letters appear as they are typed. The table dims for a moment, then shows the matching invoices, its caption counting them. On a fast machine the dimming never starts.
What deferring does, and what it doesn’t
A deferred value changes when work happens, not how much of it there is. Each of these follows from that.
Measuring the renders
The test counts passes of the filter over the invoices, one per render of the table, and records the query each pass used:
import { useDeferredValue, useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, expect, test, vi } from "vitest";
import { InvoiceSearch } from "./invoice-search";
import * as before from "./invoice-search-before";
import { InvoiceTable } from "./invoice-table";
import type * as Invoices from "./invoices";
import type { Invoice } from "./invoices";
// Each pass of the filter over the invoices is one render of the
// table: the test records the query each pass ran with.
const passes = vi.hoisted(() => [] as string[]);
vi.mock("./invoices", async (original) => {
const real = await original<typeof Invoices>();
return {
...real,
filterInvoices: (invoices: readonly Invoice[], query: string) => {
passes.push(query);
return real.filterInvoices(invoices, query);
},
};
});
const CLIENTS = [
"Brightside Studio",
"Bristow & Hale",
"Halden & Co",
"Northwind Dental",
];
const INVOICES: Invoice[] = Array.from({ length: 40 }, (_, i) => ({
id: `INV-${String(1000 + i)}`,
client: CLIENTS[i % CLIENTS.length] ?? "",
issued: `${(i % 28) + 1} Oct 2026`,
total: "€240.00",
status: i % 5 === 0 ? "open" : "paid",
}));
beforeEach(() => {
passes.length = 0;
});
test("the deferred table filters once per letter", async () => {
const user = userEvent.setup();
render(<InvoiceSearch invoices={INVOICES} />);
const search = screen.getByRole("searchbox", {
name: "Search invoices",
});
await user.type(search, "br");
expect(search).toHaveValue("br");
expect(passes).toEqual(["", "b", "br"]);
const table = screen.getByRole("table", {
name: "20 of 40 invoices",
});
expect(table.parentElement).toHaveAttribute("aria-busy", "false");
expect(screen.getAllByRole("rowheader")).toHaveLength(20);
expect(screen.getByRole("status")).toHaveTextContent(
"20 invoices match.",
);
});
test("unmemoised, each letter renders the table twice", async () => {
function WithoutMemo() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
return (
<>
<input
aria-label="Search invoices"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<InvoiceTable invoices={INVOICES} query={deferredQuery} />
</>
);
}
const user = userEvent.setup();
render(<WithoutMemo />);
await user.type(
screen.getByRole("textbox", { name: "Search invoices" }),
"br",
);
// The urgent render still runs the table with the old query, then
// the deferred render runs it again with the new one.
expect(passes).toEqual(["", "", "b", "b", "br"]);
});
test("before: once per letter, every pass blocking", async () => {
const user = userEvent.setup();
render(<before.InvoiceSearch invoices={INVOICES} />);
await user.type(
screen.getByRole("searchbox", { name: "Search invoices" }),
"br",
);
expect(passes).toEqual(["", "b", "br"]);
});The counts tell the story:
| Version | Passes | Can typing interrupt them? |
|---|---|---|
| The first page | 3: “”, “b”, “br” | No: each one blocks the field |
| Deferred, without memo | 5: “”, “”, “b”, “b”, “br” | Only the background ones |
| Deferred, with memo | 3: “”, “b”, “br” | Yes, every one after the first |
A test counts renders; it can’t feel them. To see the time, record typing in the React DevTools Profiler, or in the browser’s Performance panel with the CPU slowed down, on the largest set of invoices a customer really has.
It defers rendering, not requests
useDeferredValue doesn’t prevent extra network requests, as react.dev’s caveats say. If the table fetched per query, each keystroke would still start a request; the deferral only decides what shows while they are pending. To send fewer, debounce the request, as Debouncing input in React does. With a data source that suspends, a deferred render that is waiting for data keeps the old results on screen instead of showing the nearest Suspense fallback.
Values that stay equal
React compares the deferred value with Object.is. A string compares by its text, so “br” is equal to “br”. An object built during render is new every time and would schedule a background render on every render; defer primitives, or objects created outside render.
Stale results, said quietly
Dimming is a visual hint. aria-busy says the region is being updated. The ARIA specification lets assistive technology hold back changes inside a busy region until it settles, so a screen reader isn’t pulled through rows about to change. Keep the dim light: the rows under it are still results someone may be reading.
The first render and the server
On a component’s first render there is no older value to show, so useDeferredValue returns the value itself, and the first render is as heavy as it ever was. React 19 adds an optional second argument, initialValue: with useDeferredValue(query, ""), the first render uses the empty query, and the real one renders in the background straight after. That helps a page that arrives with a query already in its address, server-rendered or not.
React 18, 19 and the Compiler
useDeferredValue arrived in React 18; initialValue in 19. It is the counterpart of startTransition, with one difference that decides this page: an update marked as a Transition can’t drive a text input. Defer a value when one part of the screen must stay urgent, as the field does here, or when the value arrives as a prop. Inside a Transition, the hook returns the new value at once, since the update is already deferred. With the React Compiler, the parent’s JSX is memoised, so the table’s element is reused while deferredQuery is unchanged and the explicit memo becomes redundant. Keep it until the Compiler runs on that code.
When the lag isn’t rendering
Defer what is slow to render and fine to be a moment late. Feedback that must be exact at once, such as a validation message or a character count, reads the real value. When the slow part is a request, debounce it.
When the table is slow because it renders thousands of rows nobody can see, rendering fewer is a better fix than rendering them later; Virtualising long lists in React covers that. And measure first: on a list that renders quickly, the deferral adds a second render and saves nothing.
How Turtlekit’s TextField and DataTable share a deferred query
Turtlekit’s TextField and DataTable fit the same split. The field takes every keystroke through onValueChange. The table, memoised with the filter inside it, reads the deferred query, so the urgent render skips it:
"use client";
// The invoice search with Turtlekit: TextField takes every keystroke
// at once, and the DataTable, memoised, renders from the deferred
// query.
import { memo, useDeferredValue, useState } from "react";
import {
Badge,
DataTable,
EmptyState,
Stack,
TextField,
} from "turtlekit";
import { filterInvoices } from "./invoices";
import type { Invoice } from "./invoices";
const TONE = {
paid: "success",
open: "info",
overdue: "danger",
} as const;
const InvoiceResults = memo(function InvoiceResults({
invoices,
query,
}: {
invoices: readonly Invoice[];
query: string;
}) {
return (
<DataTable
caption="Invoices, 2026"
rows={filterInvoices(invoices, query)}
getRowId={(invoice) => invoice.id}
getRowLabel={(invoice) => `${invoice.id}, ${invoice.client}`}
columns={[
{ key: "id", header: "Invoice" },
{ key: "client", header: "Client" },
{ key: "issued", header: "Issued" },
{ key: "total", header: "Total", numeric: true },
{
key: "status",
header: "Status",
cell: (invoice) => (
<Badge tone={TONE[invoice.status]}>
{invoice.status}
</Badge>
),
},
]}
empty={
<EmptyState
situation="no-results"
title={`No invoices match ‘${query}’`}
/>
}
/>
);
});
export function InvoiceSearch({
invoices,
}: {
invoices: readonly Invoice[];
}) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
return (
<Stack gap="items">
<TextField
label="Search invoices"
type="search"
value={query}
onValueChange={setQuery}
/>
<InvoiceResults invoices={invoices} query={deferredQuery} />
</Stack>
);
}The field sits outside the memoised part on purpose. Put it in the table’s toolbar slot and it would become one of the table’s props, new on every keystroke, and the table would render in the urgent pass again. DataTable announces the number of matching rows itself, and its empty slot names the query when nothing matches.
When the results come from a server instead of a filter in render, DataTable’s loading does this for you: over rows already shown, it keeps them real and usable, draws a thin accent line along the table’s top edge instead of dimming the text, marks the table busy and says “Updating results…” once. Leave it off here. It is a prop, so flipping it while the field runs ahead would put the table back in the urgent pass this page works to keep it out of.
When deferring doesn’t help
Typing is still slow
The table isn’t skipping the urgent render. Check that it is memoised, and that its other props are stable: a filter object or a callback created in the parent on each render makes memo see a change. The Profiler’s “Why did this render?” names the prop.
The table never catches up while people type
That is the deferral working. Each keystroke interrupts the background render, so on a slow device the table updates when the typing pauses. If it must show something sooner, make each render cheaper by rendering fewer rows.
The results flash a loading state
Something inside the table suspended in an update that wasn’t deferred, or the Suspense boundary sits above the field. Keep the field outside the boundary and pass the deferred value in, and React shows the old results until the new ones are ready.
Let the slow part be late
The first version treated every pixel as equally urgent, so the letter people were waiting to see queued behind rows they weren’t reading yet. Deferring the query says what matters now: the field is what people are watching, and the table is what they will read next, which can arrive a moment later. In the finished page it does, only for the latest letter, and the dimming says the answer is on its way. Nothing in it is tuned to a device. A fast laptop never sees the delay, and a slow one gets a field that keeps up.