Typed input, committed URL
Wire a search box to URL state with React 19's useDeferredValue and useTransition plus nuqs debouncing, so typing stays instant and server queries stay bounded.
The invoices screen now filters and sorts. The last pillar is free-text search: a box where a user types a customer’s name and the list narrows to match. It sounds like the easiest of the four and is the hardest, because the input fires several times a second as someone types. The naive version points each keystroke straight at the URL setter; fine against twelve rows in development, a per-keystroke server storm at scale. Typing paid should narrow the list without firing four database queries, writing the URL four times, and pushing four entries onto the back button.
This is the <SearchInput /> that List-view anatomy stubbed into page.tsx, and it rests on one idea: the split between the text a user is typing and the text the server is committed to querying.
The naive search box and its three failures
Section titled “The naive search box and its three failures”The version almost everyone writes first is a controlled input whose onChange calls the nuqs setter directly. It needs one option to work at all. By default a nuqs write is shallow: it updates the URL on the client and stops, never re-rendering the server. That fits state only the client reads, but a search box drives a server-rendered list, so it passes shallow: false to make the write reach the server and re-run the query.
'use client';
// Anti-pattern — do not ship. One server query + one URL write per keystroke.export const SearchInput = () => { const [q, setQuery] = useQueryState('q', searchParser.withOptions({ shallow: false }));
return ( <input type="search" value={q} onChange={(event) => setQuery(event.target.value || null)} /> );};The two red lines are the bug: value reads straight from the URL, and onChange writes straight back on every keystroke. There’s no daylight between what’s typed and what the server queries.
Type paid and it works: the table narrows, the URL reads ?q=paid, and a refresh reproduces it. With twelve rows you’d never notice. Point it at fifty thousand rows behind a real database and three problems surface.
One: a server round-trip per character. Because shallow: false re-renders the Server Component on every URL write, every keystroke re-runs the query. Typing paid isn’t one query, it’s four, p, pa, pai, paid, and three are thrown away the instant the next key lands. Multiply that by every user and search becomes a load generator.
Two: history-entry spam. Here nuqs saves you: its writes default to history: 'replace', so the back button survives. A hand-rolled version or a careless history: 'push' rewinds the search one letter at a time, pai, pa, p, before it leaves the page. Even with replace, the rapid-fire writes and wasted renders underneath are pure waste.
Three: input lag. Tying the input’s value to URL state means the box can only repaint after the URL write, and shallow: false gates that write behind a server round-trip. On a fast laptop you won’t feel it; on a mid-range phone over a flaky connection, the letters lag behind the keys. Typing is the one thing that must always feel instant.
The first problem is the loudest. Scrub the diagram: a user types paid one character at a time, and the round-trips pile up.
Four characters, four round-trips, three discarded the instant the next key landed. Now imagine overdue, or a hundred users typing at once.
All three problems trace to one mistake: the input’s value and the server’s query are the same thing. Separate them and all three dissolve.
Typed in the component, committed in the URL
Section titled “Typed in the component, committed in the URL”Two distinct pieces of state hide inside a search box, and the naive version’s mistake was treating them as one.
- Typed is what’s in the box this instant. It changes on every keystroke, lives in component state as a plain
useState, and drives the input’svalue(the box is a controlled component). - Committed is the value the server queries against. It changes only when your typing settles, lives in the URL as
qvianuqs, and is what the database query reads on the server.
While you type, the two diverge: the box shows overdue a few keystrokes before the URL does, then reconverges once committed catches up. That divergence is the mechanism, not a glitch.
This is the URL-versus-state rule from List-view anatomy, narrowed to one box: would the user expect this back after a refresh? A shared ?q=overdue link should reproduce the search, so the committed query belongs in the URL; a half-typed ov does not, so the typed value stays local.
The box updates on every keystroke (top); the URL updates once, after the typing settles (bottom). They diverge on purpose and reconverge a beat later.
A user is mid-search. The box currently shows ove, but you inspect the URL and it still reads ?q=over from their previous, settled search — it hasn’t caught up to ove yet. A teammate files this as a bug. Are they right?
Yes — whatever is in the box must always be reflected in the URL, so any disagreement means something is broken.
No — the box and the URL hold two different pieces of state, and the URL is supposed to trail behind until the typing settles.
Yes — the fix is to feed the input’s value from the URL so the box and the address bar can never drift apart.
No, but only by accident — flip on the right nuqs option and the URL will faithfully record every keystroke as it happens.
value read straight from the URL — which is exactly what causes the per-keystroke server storm, and the fourth treats faithfully tracking every keystroke as the goal, when it’s the failure mode.useDeferredValue: an adaptive debounce
Section titled “useDeferredValue: an adaptive debounce”This first knob decides when committed catches up, and it lets React decide rather than a hand-tuned timer.
Before React 18 you reached for a setTimeout debounce: each keystroke clears the pending timer and sets a new one for, say, 300ms, so the write fires only when the user pauses. But 300 is a guess, too twitchy on a fast machine and too sluggish on a slow one. useDeferredValue adapts instead.
Build it in two moves. First, give the input its own local state, decoupled from the URL:
const [typed, setTyped] = useState(initialQuery);
<input type="search" value={typed} onChange={(event) => setTyped(event.target.value)}/>;Now a keystroke updates local state and repaints the input, nothing more. The URL is off the keystroke path, so the box stays instant no matter what the server is doing. Second, derive a deferred copy of the typed value and write that to the URL:
const deferred = useDeferredValue(typed);deferred lags typed. When updates arrive faster than React can keep up, React keeps showing the old deferred and skips the intermediate values. Type fast enough and deferred may jump straight from over to overdue, never pausing on the letters between, so the queries for o, ov, ove never happen.
useDeferredValue is a priority marker, not a speed boost. It makes nothing faster; it tells React that re-rendering against this value can wait. That is why it needs no number to tune: a fast device commits the deferred value almost immediately, a slow one lets more keystrokes coalesce first. The device sets the pace, not a hard-coded 300.
useDeferredValue(value, initialValue) takes an optional second argument for the deferred value on the first render, handy when your initial q arrives from the URL.
Mind the direction: defer the value, then write the value. It is tempting to defer the write itself by wrapping the setter in something that lags, but that defeats the purpose. The input must read typed to stay instant, and the thing that lags must be the value you commit, not the act of committing.
Here is the React-side rhythm assembled: typed state, the deferred copy, and an effect that writes the deferred value to the URL. setQuery is the merge-setter from the sort control in Filter shapes and sort.
'use client';
import { useDeferredValue, useEffect, useState } from 'react';
export const SearchInput = ({ initialQuery }: { initialQuery: string }) => { const [typed, setTyped] = useState(initialQuery); const deferred = useDeferredValue(typed);
// Sync the settled value to the URL — the URL is the external system. useEffect(() => { setQuery({ q: deferred || null, cursor: null }); }, [deferred]);
return ( <input type="search" value={typed} onChange={(event) => setTyped(event.target.value)} /> );};The input’s own state, seeded from the URL’s current q via the initialQuery prop. This is what the box renders and the only thing a keystroke touches.
'use client';
import { useDeferredValue, useEffect, useState } from 'react';
export const SearchInput = ({ initialQuery }: { initialQuery: string }) => { const [typed, setTyped] = useState(initialQuery); const deferred = useDeferredValue(typed);
// Sync the settled value to the URL — the URL is the external system. useEffect(() => { setQuery({ q: deferred || null, cursor: null }); }, [deferred]);
return ( <input type="search" value={typed} onChange={(event) => setTyped(event.target.value)} /> );};The lagging copy. React keeps it behind typed during fast typing and may skip intermediate values, making the rhythm adaptive instead of a fixed timer.
'use client';
import { useDeferredValue, useEffect, useState } from 'react';
export const SearchInput = ({ initialQuery }: { initialQuery: string }) => { const [typed, setTyped] = useState(initialQuery); const deferred = useDeferredValue(typed);
// Sync the settled value to the URL — the URL is the external system. useEffect(() => { setQuery({ q: deferred || null, cursor: null }); }, [deferred]);
return ( <input type="search" value={typed} onChange={(event) => setTyped(event.target.value)} /> );};When the deferred value settles, write it to the URL — an effect syncing component state to an external system, its sanctioned use. || null clears the param when the box is empty; cursor: null is the reset invariant, covered below.
'use client';
import { useDeferredValue, useEffect, useState } from 'react';
export const SearchInput = ({ initialQuery }: { initialQuery: string }) => { const [typed, setTyped] = useState(initialQuery); const deferred = useDeferredValue(typed);
// Sync the settled value to the URL — the URL is the external system. useEffect(() => { setQuery({ q: deferred || null, cursor: null }); }, [deferred]);
return ( <input type="search" value={typed} onChange={(event) => setTyped(event.target.value)} /> );};The box reads typed, never deferred and never the URL, which keeps it instant. Cross those wires and the box goes laggy again.
So committed catches up adaptively, once typing settles. A server render still has to run when it does, and we have not said what the user sees while it is in flight.
useTransition: keeping the input responsive during the re-render
Section titled “useTransition: keeping the input responsive during the re-render”useDeferredValue chose when to commit; the commit itself triggers a server re-render, and that’s where useTransition earns its place.
Writing the URL with shallow: false makes the server re-render the page and stream back a fresh table, which takes time. If React treats that update as urgent, it blocks the UI until the new render arrives, and the box feels sticky. Mark the URL write as a transition instead:
const [isPending, startTransition] = useTransition();
useEffect(() => { startTransition(() => { setQuery({ q: deferred || null, cursor: null }); });}, [deferred]);
<input type="search" value={typed} onChange={handleChange} aria-busy={isPending} />;The write and the Server Component re-render it triggers are now non-urgent, so React keeps the input and the current table interactive instead of blocking on the new render. Like useDeferredValue, useTransition is a priority marker, not a speed boost: same idea, applied to the write.
It also hands you isPending, true while that render is in flight. Use it for a subtle loading affordance on the input or table.
The two hooks compose: useDeferredValue decides when the committed value catches up, while useTransition keeps the input responsive while it does and gives you isPending for the loading state.
Bounding URL writes with nuqs limitUrlUpdates
Section titled “Bounding URL writes with nuqs limitUrlUpdates”useDeferredValue coalesces keystrokes for rendering, but as typing settles the deferred value can still change several times, and with shallow: false each change is a real server query. Deferral thins those writes; it doesn’t cap them. The cap is a nuqs option set on the parser: limitUrlUpdates, built from debounce or throttle, both imported from nuqs.
// deprecated in nuqs 2.5 — do not use in new code.useQueryState('q', searchParser.withOptions({ throttleMs: 200 }));throttleMs was deprecated in nuqs 2.5.0 and will be removed in a later major. Migrate any { throttleMs: 200 } to limitUrlUpdates.
import { debounce } from 'nuqs';
useQueryState('q', searchParser.withOptions({ shallow: false, limitUrlUpdates: debounce(300) }));limitUrlUpdates with a limiter. Import debounce (or throttle) and give it a millisecond budget. debounce(300) waits until writes stop for 300ms, then commits once; shallow: false rides alongside so the write reaches the server.
Why debounce and not throttle? It’s the difference between a search box and a slider. debounce(ms) queries only the settled value: type o, ov, ove, pause, and only the pause queries — which is what a search box wants. throttle(ms) fires during a continuous stream, which is a slider being dragged, where the intermediate values matter. Free-text search wants debounce, around 300ms.
If useDeferredValue already coalesces keystrokes, why also debounce the URL? They bound different things. Deferral is about render priority on this device: how much React work happens and when, adapted to the hardware. The debounce is about not hitting the server on partial input, a threshold that means the same everywhere. One protects the render loop, the other protects the history stack and the database.
A small client-side list has no server to spare, so you can lean on useDeferredValue alone and skip the debounce. The debounce earns its line here because shallow: false makes each write a real server round-trip.
Two of nuqs’s defaults are already right for a search box, so you write nothing for them:
historydefaults to'replace', so the back button survives a search session. Reaching for'push'is the per-letter-rewind anti-pattern from the first section.scrolldefaults tofalseinnuqssetters, unlike a rawrouter.replace, so the long list won’t jump and there is no{ scroll: false }to remember.
The empty query and the cursor reset
Section titled “The empty query and the cursor reset”An empty box must omit the parameter, not write ?q=. When the user clears the search, the URL should return to the clean home view, /invoices. Because searchParser is parseAsString.withDefault(''), the default-stripping rule drops any value equal to the default. Passing q: deferred || null coerces the empty string to null, stripping the parameter.
Changing q resets the cursor. A cursor marks a spot in the current result set, so a new q makes the old cursor point at nothing. Bundle cursor: null into the same write as the q change; nuqs will not clear it for you. That is why the write is setQuery({ q: deferred || null, cursor: null }), never setQuery({ q }).
Below are the events of one settled keystroke burst, scrambled. Drag them into the order they fire.
A user types a few letters into the search box and pauses. Put the events in the order they fire. Drag the items into the correct order, then press Check.
typed state updates on each keystroke and the box repaints instantly. deferred lags behind and skips the intermediate keystrokes. debounce(300) window elapses with no new writes. startTransition runs setQuery({ q: deferred || null, cursor: null }) with shallow: false — isPending flips true. q and queries the database. isPending flips false; the busy affordance clears. Assembling the full search input
Section titled “Assembling the full search input”The real <SearchInput /> is the one List-view anatomy stubbed as <SearchInput initialQuery={q} /> in page.tsx: a Client Component at app/invoices/_components/search-input.tsx that takes the URL’s current q as initialQuery and wraps a native type="search" input. Five concerns, about thirty lines.
'use client';
import { debounce, useQueryStates } from 'nuqs';import { useDeferredValue, useEffect, useState, useTransition } from 'react';
import { cursorParser, searchParser } from '../searchParams';
export const SearchInput = ({ initialQuery }: { initialQuery: string }) => { const [typed, setTyped] = useState(initialQuery); const deferred = useDeferredValue(typed); const [isPending, startTransition] = useTransition();
const [, setQuery] = useQueryStates( { q: searchParser, cursor: cursorParser }, { shallow: false, limitUrlUpdates: debounce(300) }, );
// Sync the settled value to the URL — the URL is the external system. useEffect(() => { startTransition(() => { setQuery({ q: deferred || null, cursor: null }); }); }, [deferred]);
return ( <input type="search" value={typed} onChange={(event) => setTyped(event.target.value)} aria-busy={isPending} /> );};Typed state, seeded from the server’s q. The box reads and writes only this, which is why typing is always instant.
'use client';
import { debounce, useQueryStates } from 'nuqs';import { useDeferredValue, useEffect, useState, useTransition } from 'react';
import { cursorParser, searchParser } from '../searchParams';
export const SearchInput = ({ initialQuery }: { initialQuery: string }) => { const [typed, setTyped] = useState(initialQuery); const deferred = useDeferredValue(typed); const [isPending, startTransition] = useTransition();
const [, setQuery] = useQueryStates( { q: searchParser, cursor: cursorParser }, { shallow: false, limitUrlUpdates: debounce(300) }, );
// Sync the settled value to the URL — the URL is the external system. useEffect(() => { startTransition(() => { setQuery({ q: deferred || null, cursor: null }); }); }, [deferred]);
return ( <input type="search" value={typed} onChange={(event) => setTyped(event.target.value)} aria-busy={isPending} /> );};The lagging copy that coalesces keystrokes and skips intermediates: when the URL catches up.
'use client';
import { debounce, useQueryStates } from 'nuqs';import { useDeferredValue, useEffect, useState, useTransition } from 'react';
import { cursorParser, searchParser } from '../searchParams';
export const SearchInput = ({ initialQuery }: { initialQuery: string }) => { const [typed, setTyped] = useState(initialQuery); const deferred = useDeferredValue(typed); const [isPending, startTransition] = useTransition();
const [, setQuery] = useQueryStates( { q: searchParser, cursor: cursorParser }, { shallow: false, limitUrlUpdates: debounce(300) }, );
// Sync the settled value to the URL — the URL is the external system. useEffect(() => { startTransition(() => { setQuery({ q: deferred || null, cursor: null }); }); }, [deferred]);
return ( <input type="search" value={typed} onChange={(event) => setTyped(event.target.value)} aria-busy={isPending} /> );};The write becomes a non-urgent transition (below), and isPending drives the quiet busy state, so typing never blocks on the server render.
'use client';
import { debounce, useQueryStates } from 'nuqs';import { useDeferredValue, useEffect, useState, useTransition } from 'react';
import { cursorParser, searchParser } from '../searchParams';
export const SearchInput = ({ initialQuery }: { initialQuery: string }) => { const [typed, setTyped] = useState(initialQuery); const deferred = useDeferredValue(typed); const [isPending, startTransition] = useTransition();
const [, setQuery] = useQueryStates( { q: searchParser, cursor: cursorParser }, { shallow: false, limitUrlUpdates: debounce(300) }, );
// Sync the settled value to the URL — the URL is the external system. useEffect(() => { startTransition(() => { setQuery({ q: deferred || null, cursor: null }); }); }, [deferred]);
return ( <input type="search" value={typed} onChange={(event) => setTyped(event.target.value)} aria-busy={isPending} /> );};The merge-setter. Whole-hook options are the second argument: shallow: false reaches the server, and limitUrlUpdates: debounce(300) bounds the writes. The per-key map holds only parsers.
'use client';
import { debounce, useQueryStates } from 'nuqs';import { useDeferredValue, useEffect, useState, useTransition } from 'react';
import { cursorParser, searchParser } from '../searchParams';
export const SearchInput = ({ initialQuery }: { initialQuery: string }) => { const [typed, setTyped] = useState(initialQuery); const deferred = useDeferredValue(typed); const [isPending, startTransition] = useTransition();
const [, setQuery] = useQueryStates( { q: searchParser, cursor: cursorParser }, { shallow: false, limitUrlUpdates: debounce(300) }, );
// Sync the settled value to the URL — the URL is the external system. useEffect(() => { startTransition(() => { setQuery({ q: deferred || null, cursor: null }); }); }, [deferred]);
return ( <input type="search" value={typed} onChange={(event) => setTyped(event.target.value)} aria-busy={isPending} /> );};When deferred settles, write q and reset the cursor in one atomic call inside the transition. This is the effect’s sanctioned use, syncing local state to the URL. || null clears on empty; cursor: null is the reset invariant.
The server didn’t change. The page still does its one read and one query exactly as List-view anatomy wrote it, reading q from the URL like any other parameter, oblivious to how smoothly it got there. That is the payoff of the split: the input’s rhythm stays contained in one Client Component.
What the query does with q is a separate, database question. A small list might use a substring ilike; production scale reaches for Full-text search. The URL-state side you built is shape-agnostic: it hands the server a string, and what to match against is the database’s concern.
Here is one accessibility pass, the contract only, since No ARIA over bad ARIA owns the depth. A native type="search" input already announces itself as a search field, so role="searchbox" comes for free. Two wires complete it: aria-controls on the input pointing at the results table’s id, so screen-reader users know the box drives that table, and an aria-live="polite" region near the table that announces the result count when the rows update.
<input type="search" aria-controls="invoice-results" aria-busy={isPending} />;
<table id="invoice-results">{/* rows */}</table>;
<p role="status" aria-live="polite" className="sr-only"> {rows.length} results</p>;role="status" is the live region for non-urgent updates. It must be mounted before the count fills it, so the announcement fires when the table updates rather than on first render.
Now wire the rhythm yourself, the React-only slice: no nuqs, no URL. The exercise below hands you a janky search box, a controlled input pointed at a deliberately slow filter that runs on every keystroke, so the box stutters as you type. Apply the same primitives, useState, useDeferredValue, and useTransition, to an in-memory filter.
This search box is janky: one piece of state drives both the input and an expensive per-keystroke filter, so the box stutters as you type. Rebuild the rhythm with the durable React primitives. Give the input its own typed state so it repaints instantly; derive a useDeferredValue from it and run filterRows against THAT lagging copy; and set data-pending on the results region to the string true while the deferred value is still catching up to what is typed, flipping it to false once they agree. Do not touch filterRows or the seed data.
Reference solution
typed is the input’s own state, so the box is instant. useDeferredValue(typed) is the lagging copy that drives the expensive filterRows. data-pending is simply whether the two still disagree: true while the deferred value is mid-catch-up, false once it settles. (useTransition works just as well here, driving the flag via isPending; the deferred !== typed comparison is the leanest version when a single value feeds the slow render.)
import { useDeferredValue, useState } from 'react';
export function App() { const [typed, setTyped] = useState(''); const deferred = useDeferredValue(typed); const rows = filterRows(deferred); const isPending = deferred !== typed;
return ( <div className="p-4"> <input type="search" value={typed} onChange={(event) => setTyped(event.target.value)} placeholder="Search invoices…" className="w-full rounded border px-3 py-2" /> <div data-results data-pending={isPending ? 'true' : 'false'} className="mt-3 text-sm text-gray-600" > {rows.length} results </div> </div> );}When filter-as-you-type is the wrong default
Section titled “When filter-as-you-type is the wrong default”Filter-as-you-type is the right default for most list views, but not all. When the data source is slow, expensive, or rate-limited, say an external search API you pay for per call, or when the product wants a deliberate search-on-submit feel, committing on every settled pause is wrong; commit on blur or Enter instead.
Drop useDeferredValue, keep the input as plain typed state, and write the URL only from onKeyDown (Enter) and onBlur.
const commit = () => setQuery({ q: typed || null, cursor: null });
<input type="search" value={typed} onChange={(event) => setTyped(event.target.value)} onBlur={commit} onKeyDown={(event) => { if (event.key === 'Enter') { commit(); } }}/>;The setQuery call is unchanged: same || null clear, same bundled cursor reset. Only the trigger moves, from a settled pause to an explicit blur or Enter.
The share-and-refresh test
Section titled “The share-and-refresh test”The litmus test, pointed at the search box: if a click or a settled keystroke changes the result but not the URL, the contract is broken. Search for overdue, copy the address, and open it in a new tab; you should land on the same filtered list.
One stub is left in page.tsx. The next lesson builds <Pagination /> and cracks open the cursor this lesson and the last treated as a black box, the position the reset invariant has been clearing all along.
The hook reference, including the second initialValue argument and why it's a priority marker rather than a debounce.
The current rate-limiting API that replaced throttleMs, plus the shallow and history options this lesson sets.