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 while server queries stay bounded.
The invoices screen now filters and sorts. One pillar is left: free-text search, the box where a user types a customer’s name and the list narrows to match. It sounds like the easiest of the four, but it bites the hardest, because a search box is wired to a person tapping keys several times a second. The naive version points the input straight at the URL setter. It looks fine in development with twelve rows and turns into a per-keystroke server storm the moment it ships. So the question here isn’t “how do I read a search term.” It’s a question of rhythm: how do you wire a box so that typing paid filters the list without firing four database queries, writing the URL four times, and stacking four entries onto the back button?
This is the <SearchInput /> that The list-view anatomy stubbed into page.tsx and that Filter shapes and sort encoding left waiting. The architecture doesn’t change: same read-on-server/write-on-client split, same searchParams.ts module, same reset invariant when the result set shifts. What’s new is one mental model that everything else in this lesson hangs off, the split between the text a user is typing and the text the server is committed to querying against. Hold that split and the rest is three small, independent knobs, so read the lesson looking for it.
The naive search box, and the three ways it breaks
Section titled “The naive search box, and the three ways it breaks”Start with the version almost everyone writes first, because naming why it fails is what gives the fix its weight. It’s a controlled input whose onChange calls the nuqs setter directly. There’s one option it has to set to work at all. By default a nuqs write is shallow: it updates the URL on the client and stops there, never telling the server to re-render. That’s the right default for state only the client reads, but a search box drives a server-rendered list, so it must pass shallow: false to make the write reach the server and re-run the query. Keep shallow: false in mind, because it is the single load-bearing option in this lesson and it comes back several times. For now, it’s what makes the search reach the database at all.
'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 working together: the input’s value reads straight from the URL, and onChange writes straight back to it on every keystroke. There’s no daylight between what’s typed and what the server queries.
Type paid into that box and it works: the table narrows, the URL reads ?q=paid, and a refresh reproduces it. With twelve rows in development you’d ship this and never notice. Now picture it against fifty thousand rows behind a real database, and three distinct problems surface. Each one is worth naming precisely, because each gets its own fix later.
One: a server round-trip per character. Because shallow: false makes every URL write re-render the Server Component, every keystroke re-runs the database query. Typing paid isn’t one query, it’s four: p, pa, pai, paid. Three of them are thrown away the instant the next key lands. Multiply that by every user typing in the box and you’ve turned a search feature into a load generator.
Two: history-entry spam. Here nuqs quietly saves you from the worst version, because its writes default to history: 'replace', so the back button survives. But that default is exactly the thing a hand-rolled version, or a careless history: 'push', gets wrong. Push on every keystroke and the back button rewinds the search one letter at a time, pai, pa, p, before it finally leaves the page. Push-on-keystroke is the canonical anti-pattern. Even with replace saving the history, the rapid-fire writes and wasted renders underneath are still pure waste.
Three: input lag. Tying the input’s value to URL state means the box can only repaint after the URL write, and with shallow: false that write is gated 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. The field should never wait on the query; typing is the one thing that must always feel instant.
That first problem is the loudest, so let’s see it. Scrub through the diagram below: a user types paid, one character at a time, and watch the round-trips pile up.
Four characters, four full round-trips, three of them discarded the instant the next key landed. Now imagine overdue, or a hundred users typing at once.
Every one of those three problems traces back to a single design mistake: the input’s value and the server’s query are the same thing. Separate them and all three dissolve. That’s the next section.
Typed vs. committed: two states, two homes
Section titled “Typed vs. committed: two states, two homes”Here is the whole lesson in one idea. 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, the value you see as you tap keys. It changes on every keystroke. It lives in component state, in a plain
useState, and it drives the input’svalue(the box is a controlled component). - Committed is the value the server is actually querying against, what’s in the URL’s
q. It changes only when your typing settles. It lives in the URL, vianuqs, and it’s what the database query reads on the server.
While you type, these two diverge. The box says overdue a few keystrokes before the URL does. Then, a beat after you stop, they reconverge as the committed value catches up to the typed one. That divergence is not a glitch to eliminate; it is the entire mechanism. The naive box failed precisely because it refused to let them diverge: it forced committed to track typed on every single keystroke, which is what generated the storm.
State the decision rule and keep it:
This is the same URL-versus-state rule from The list-view anatomy, sharpened to a single box. The litmus question there was: would the user expect this back after a refresh? Run it on both halves. The committed query passes, because a shared ?q=overdue link should reproduce the search, so it belongs in the URL. The half-typed ov fails, because nobody wants a link that reopens to someone’s three-letters-in keystroke, and nobody wants a back-button entry per letter. So the typed value stays local in the component, and only the settled value is ever written to the URL. The split isn’t an optimization bolted on top of the rule; it is the rule, applied to the one piece of state that changes faster than the URL should.
The picture below shows the two values as two tracks over time. The top track is typed, ticking up with every keystroke as the user spells out overdue. The bottom track is committed, holding flat at the old value while the typing happens, then snapping to the new value once the typing settles.
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. Everything else in this lesson is a decision about when that bottom pill fires and how to keep the box smooth while it does.
A common misread of this picture is to see the gap, where typed says ove and the URL still says the old value, and call it a bug. It isn’t. Mid-stroke disagreement is the design working. Here’s one quick check before we wire it.
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.So the design is settled: typed in the component, committed in the URL, diverging while you type. Three questions remain, and each is a separate knob. When does committed catch up? How do we keep the box smooth while the resulting server render is in flight? And how do we keep even the settled writes from piling up? Three sections, three knobs. Conflating them is the trap, so we take them one at a time.
Letting React choose the rhythm: useDeferredValue
Section titled “Letting React choose the rhythm: useDeferredValue”The first knob answers when does committed catch up, and the 2026 answer is to let React decide rather than a hand-tuned timer.
The pre-React-18 move here was a setTimeout debounce: on each keystroke, clear the pending timer and set a new one for, say, 300ms, so the write fires only when the user pauses long enough. It works, but you’re hard-coding a number that’s wrong on half your users’ devices, too twitchy on a fast machine and too sluggish on a slow one. React 19 gives you a primitive that adapts instead: useDeferredValue.
Build it in two moves. First, give the input its own local state, fully decoupled from the URL:
const [typed, setTyped] = useState(initialQuery);
<input type="search" value={typed} onChange={(event) => setTyped(event.target.value)}/>;The box does nothing now but track its own text: a keystroke updates local state, and local state repaints the input. The URL has been pulled out of the keystroke path entirely, so the box is instant no matter what the server is doing.
That box is already instant, since it’s a plain controlled input with no server in its way. But nothing reaches the URL yet. The second move is the new part: derive a deferred copy of the typed value, and write that to the URL.
const deferred = useDeferredValue(typed);deferred lags typed. When updates pile up faster than React can keep up, React keeps showing you the old deferred and skips intermediate values, which is the key behavior. Type fast enough and deferred may jump straight from over to overdue, never pausing on the letters in between. That skipping is exactly the wasted-render elimination the storm diagram called for: the discarded queries for o, ov, ove simply never happen, because deferred never took those values.
Here is the mental model to lock in, because it’s the one people get backwards. useDeferredValue is a priority marker, not a speed boost. It doesn’t make anything faster. It tells React that re-rendering against this value is non-urgent and should be deprioritized. That’s why it’s adaptive with no number to tune: on a fast device the deferred value commits almost immediately, while on a slow one React lets more keystrokes coalesce before it catches up. The device sets the rhythm, not a hard-coded 300.
One React 19 detail is worth a single sentence: useDeferredValue(value, initialValue) takes an optional second argument for what the deferred value should be on the very first render. That’s handy when your initial q arrives from the URL, so the deferred value starts there instead of empty. Useful to know, but not load-bearing for the rest of this lesson.
Now the direction trap, because it’s the documented way to get this wrong. The deferred value drives the URL write; the typed value drives the input. It is tempting to instead defer the URL write itself, by wrapping the setter in something that lags. That defeats the entire purpose: the input must read typed so it stays instant, and the thing that lags must be the value you then commit, not the act of committing. Defer the value, then write the value. Get that arrow backwards and you’ve just rebuilt the laggy box.
Here’s the React-side rhythm assembled: typed state, the deferred copy, and an effect that writes the deferred value to the URL. The setQuery here is the merge-setter you met for the sort control in Filter shapes and sort encoding; we’ll wire up its exact useQueryStates call in the final assembly, where the nuqs options also land. Step through each line.
'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, which arrives as the initialQuery prop the page hands down. This is the value 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 entirely. This is the priority marker that makes 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. The effect synchronizes component state with an external system, the URL, which is its sanctioned use; the comment records that so it isn’t mistaken for a derive-in-effect smell. || null clears the param when the box is empty, and 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 is what keeps it instant. Deferred drives the URL; typed drives the input. Cross those wires and the box goes laggy again.
That’s when committed catches up: adaptively, when typing settles, with the intermediate keystrokes skipped. But there’s still a server render to run when it does, and we haven’t said what the user sees while that render is in flight. That’s a different problem and the next knob.
Keeping the box responsive while the list catches up: useTransition
Section titled “Keeping the box responsive while the list catches up: useTransition”useDeferredValue decided when the URL commits. useTransition is about what the user sees while the resulting server re-render is in flight. These solve different problems, so keep them separate in your head or you’ll think one makes the other redundant.
When the deferred value writes the URL with shallow: false, the server re-renders the page and streams back a fresh table. That work takes time. Without help, React would treat the update as urgent and could block the UI on it, which is exactly what makes a box feel sticky. The fix is to mark the URL write as a transition:
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 re-render it triggers are now a non-urgent transition, so the box stays interactive while the new table loads. isPending is true for the duration of that work and drives a quiet busy state on the input.
Wrapping the write in startTransition tells React the navigation and the Server Component re-render it triggers are non-urgent, so React keeps the input and the currently-shown table interactive instead of blocking on the new render. Like useDeferredValue, useTransition is a priority marker, not a speed boost: it doesn’t make the query faster, it changes how React schedules the work around it.
The bonus is isPending, a boolean that’s true while the transition’s work is in flight. That’s your loading affordance, and the rule is that it must be subtle: a small spinner inside the input, or a quiet dimming of the table while the new rows arrive. Never a full-screen blocker, never a disabled input. The anchor:
Now the distinction the whole section exists to draw, because conflating these two hooks is the most common confusion in this material:
useDeferredValuekeeps the render non-urgent and coalesces keystrokes. It decides when the committed value catches up.useTransitionkeeps the input responsive while the commit’s server render runs, and hands youisPendingfor the loading state. It decides what the user sees while it catches up.
They aren’t alternatives. You use both, and they compose: defer the value to choose the moment, then wrap the write in a transition to stay smooth through it. Two knobs, two jobs.
Bounding the URL writes: nuqs limitUrlUpdates
Section titled “Bounding the URL writes: nuqs limitUrlUpdates”Two knobs down, both on the React side. The third lives in nuqs, and it solves a problem the first two don’t touch.
Here’s the gap. useDeferredValue coalesces keystrokes for rendering, but as typing settles the deferred value can still change a few times. Each change is still a URL write, and with shallow: false each write is a real server query. Deferral thins the storm; it doesn’t put a hard floor under how often the URL is allowed to change. That floor is a nuqs concern, and the option is limitUrlUpdates.
You attach it to the parser’s options. The current API takes a limiter 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 is slated for removal in a later major version. Any tutorial or snippet still passing { throttleMs: 200 } predates the change. If you copy one, migrate it to limitUrlUpdates before it disappears.
import { debounce } from 'nuqs';
useQueryState('q', searchParser.withOptions({ shallow: false, limitUrlUpdates: debounce(300) }));limitUrlUpdates with a limiter. Import debounce (or throttle) from nuqs and hand it a millisecond budget. debounce(300) waits until writes stop for 300ms, then commits once, which is the right shape for a search box. shallow: false rides alongside so the write reaches the server.
So why debounce and not throttle for search? The two limiters have different shapes, and the difference is exactly the difference between a search box and a slider:
- debounce(ms) waits until writes stop for
ms, then commits once. Type o, ov, ove, then pause, and only the pause triggers the write. That’s a search box: you want the settled query, not the partial ones. - throttle(ms) commits at most once per
mswindow, firing periodically during a continuous stream. That’s a slider being dragged, where you want intermediate updates rather than just the final resting value.
For free-text search, the experienced pick is debounce, around 300ms. You don’t want to query o, ov, ove; you want to wait for the pause and query once.
That naturally raises a question: if useDeferredValue already coalesces keystrokes, why also debounce the URL? Because they bound different things. Deferral is about render priority on this device: it decides how much React work happens and when, and it adapts to the hardware. The debounce is about not writing the URL and not hitting the server on partial input, a threshold that’s meaningful outside the browser and the same on every device. One protects the render loop; the other protects the history stack and the database. They’re complementary, not redundant.
One honest note. For a small in-memory list, say a few hundred rows you filter client-side, you might lean entirely on useDeferredValue and skip the debounce, since there’s no server to spare. The debounce earns its weight precisely because shallow: false is in play: each committed write is a genuine server round-trip, and bounding those is worth a line of config. Match the knob to the cost.
Now the flip side, because the reflex this lesson should leave you with is to under-configure, not over-configure. Two of nuqs’s defaults are already correct for a search box, so you write nothing for them:
historydefaults to'replace'. The back button already survives a search session. Do not reach for'push'on a search box; that’s the per-letter-rewind anti-pattern from the first section.scrolldefaults tofalseinnuqssetters, unlike a rawrouter.replace, which scrolls to the top. So there’s no{ scroll: false }to remember, and the long list won’t jump.
Which leaves exactly two options the search write actually needs, and no more:
The empty query and the cursor reset
Section titled “The empty query and the cursor reset”Two short rules finish the contract. Both reuse patterns this chapter already established, so they’re quick.
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, with no trailing ?q=. You already have the machinery for this for free. searchParser is parseAsString.withDefault(''), and the default-stripping rule from The list-view anatomy says any value equal to the parser’s default is stripped from the URL. So the write passes q: deferred || null: the empty string coerces to null, which clears the parameter. A “nothing searched” link then matches the default list exactly, the same as a fresh visit.
Changing q resets the cursor, the reset invariant now applied to search. This is the named rule from Filter shapes and sort encoding, and it lands here unchanged. A new search changes what rows are shown, which invalidates any pagination position: a cursor encodes a spot in the current ordered, filtered, searched result, and a new q produces a new result, so the old cursor points at nothing meaningful. The fix is the same structural move: bundle cursor: null into the same write as the q change, never a separate call. That’s why the URL write throughout this lesson has been setQuery({ q: deferred || null, cursor: null }) rather than just setQuery({ q }). nuqs will not clear the cursor for you; the reset is yours to bundle in. This is the identical rule the filter and sort controls used, and the one the pagination edges will use next: every parameter that changes the result set resets the page in the same breath.
Now put the moment in order. Below are the events of one settled keystroke burst, scrambled. Drag them into the sequence they actually fire in.
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. The search input, end to end
Section titled “The search input, end to end”Every piece has been built in isolation. Now assemble the real <SearchInput />, the one The list-view anatomy stubbed as <SearchInput initialQuery={q} /> in page.tsx. It lives at app/invoices/_components/search-input.tsx, it’s a Client Component, it takes the URL’s current q as initialQuery, and it’s a native type="search" input. Five concerns, all of which you now recognize, in 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 the reason 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. Knob one: 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} /> );};Knob two. 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) is knob three, bounding 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, wrapped in the transition. The effect synchronizes local state with the URL, its sanctioned use, and the comment records why. || null clears on empty, and cursor: null is the reset invariant.
Step back and notice what didn’t change: the server. The page still does its one read and one query exactly as The list-view anatomy wrote it, with const { q, ... } = await searchParamsCache.parse(props.searchParams), then listInvoices({ q, ... }). This whole lesson lived entirely on the client, in the rhythm between typed and committed. The server just reads q from the URL like any other parameter, oblivious to how smoothly it got there. That obliviousness is the payoff of the split: the hard part is the input’s rhythm, and it’s fully contained in one Client Component.
What the query does with q is a separate question, and a database one. A small list might use a substring ilike, while production scale reaches for Postgres full-text search. That spectrum lives in Full-text search in Postgres; the URL-state side you built here is shape-agnostic. It hands the server a string, and what the server matches against is the database’s concern, not the URL’s.
Here’s one accessibility pass, the contract only, since No ARIA is better than bad ARIA owns the depth. A native type="search" input already announces itself as a search field to assistive tech, so you get role="searchbox" for free. Two more wires make it complete. First, an aria-controls on the input pointing at the results table’s id, so screen-reader users know the box drives that table. Second, an aria-live="polite" region near the table that announces the result count, such as “5 results”, when the rows update, so a screen-reader user learns the search resolved without having to go hunting for the change.
<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, the convention from the accessibility chapter. It’s 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 exercise below hands you a janky search box: a controlled input pointed straight at an onSearch prop that runs a deliberately slow filter on every keystroke, so the box stutters as you type. Your job is to apply the durable React primitives from this lesson: give the input its own useState, derive a useDeferredValue to drive the filtering, and wrap the work in useTransition so the box stays smooth and a data-pending flag flips while the filter runs. This is the React-only slice, with no nuqs and no URL, but it’s the transferable skill: the exact same rhythm you just wired to the URL, here driving 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 from this lesson works just as well to drive that 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 typing-as-you-go is the wrong default
Section titled “When typing-as-you-go is the wrong default”The whole lesson assumed filter-as-you-type, which is the right default for most list views. But it’s worth naming the case it doesn’t cover, because picking the default knowingly is the skill.
If the data source is slow, expensive, or rate-limited, say an external search API you pay for per call, or the product deliberately wants a search-on-submit feel, then committing on every settled pause is wrong. There you commit on blur or Enter instead: drop useDeferredValue entirely, keep the input as plain typed state, and write the URL only from onKeyDown (Enter) and onBlur. The write itself is unchanged: still shallow: false, still bundling the cursor reset.
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. The only difference is when it fires, on an explicit blur or Enter rather than on a settled pause. No useDeferredValue is involved; the commit is now an explicit user action.
This is the older “form that submits to the URL on Enter” pattern, and it’s still perfectly correct, a deliberate product choice rather than a fallback. The React 19 deferred-value flow is simply the 2026 default for filter-as-you-go. Knowing both, and which one a given data source calls for, is what separates reaching for a recipe from making a decision.
What this lesson built, and what comes next
Section titled “What this lesson built, and what comes next”The whole lesson reduces to one split and three knobs hung off it:
- Typed vs. committed. The input is controlled by typed state (the component); the server is controlled by committed state (the URL). They diverge while you type and reconverge once it settles. That divergence is the design, not a bug.
useDeferredValue. Knob one, a priority marker that coalesces keystrokes and skips intermediates, deciding adaptively when the URL commits with no timer to tune.useTransition. Knob two, marking the URL write non-urgent so the box stays responsive through the server render, withisPendingdriving a quiet loading affordance. Never block the input.limitUrlUpdates: debounce(300). Knob three, at thenuqslayer, bounding the URL writes (and so the server queries) on partial input.debouncefor search,throttlefor sliders;throttleMsis the deprecated pre-2.5 spelling.shallow: false. The one option that makes the committed write actually reach the server-rendered list. The rest of the rhythm only matters because each write is a real query.- The reset invariant and empty-query stripping.
cursor: nullbundled into the same write as theqchange, andq: deferred || nullso an empty box returns the clean home URL.
The acceptance test is the chapter’s, 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. Run that against the box you just built.
One stub is left in page.tsx. The next lesson builds <Pagination />, makes the cursor-by-default call over offset, and finally cracks open the opaque cursor that this lesson and the last treated as a black box, the position that the reset invariant has been faithfully clearing all along. Same searchParams.ts, same page shape. The last stub, then the screen is whole.
If you want the reference material, these two are worth a bookmark.
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.
External resources
Section titled “External resources”The second knob and the third both deserve a look beyond this lesson’s framing.