The classic janky search page, in three lines:
const [query, setQuery] = useState("");
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<FilteredList query={query} /> // renders 10,000 rows per keystroke
// every keypress waits for the list -> typing feels brokenThe input value and the list render are fighting for the same render slot, and React runs them first-come. Transitions let you tell React which update is urgent and which can be interrupted.
Step 1: Split urgent from expensive
Every janky screen has two updates. The urgent one the user needs now: the characters in the input, the active tab. The expensive one that can wait: the filtered list, the re-rendered results. Name both before writing code.
const [query, setQuery] = useState(""); // urgent: the input text
const [filter, setFilter] = useState(""); // expensive: drives the listIf you cannot name the two updates, a transition will not help, because you will end up marking the urgent one expensive.
Step 2: Mark the expensive update as a transition
useTransition returns isPending and startTransition. The state lives where it always did; only the update is flagged non-urgent:
import { useTransition } from "react";
function Search() {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState("");
const [isPending, startTransition] = useTransition();
return (
<>
<input
value={query}
onChange={(e) => {
setQuery(e.target.value); // urgent: types instantly
startTransition(() => {
setFilter(e.target.value); // interruptible render
});
}}
/>
<FilteredList query={filter} />
</>
);
}The input updates on the normal priority, the list re-renders at low priority. React interrupts the list render every time a new keystroke arrives, so typing never queues behind a 200ms render of a list that is already obsolete.
Step 3: Show the pending state honestly
isPending is true while the transition render runs. Use it:
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<FilteredList query={filter} />
</div>A dimmed list beats a frozen input, and it beats pretending the update was instant. Never fake it with setTimeout; the timeout still blocks the next keystroke and does not tell React anything about priority. That is the exact bug transitions replace.
Step 4: Reach for useDeferredValue at the leaf
When the expensive work happens inside a component you cannot edit, useDeferredValue is the same mechanism at a different insertion point:
function SearchBox({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<FilteredList query={deferredQuery} />
</div>
);
}The parent keeps one state; the component renders twice, once with the fresh value for the input and once with the lagging value for the list. Hooks go where the state lives, deferred values go where the rendering happens.
Step 5: Do not transition everything
Transitions are for updates that render a lot and stay renderable:
// good transition candidates
startTransition(() => setFilter(text)); // big list re-render
startTransition(() => setTab(next)); // heavy tab content
// keep these urgent
setName(e.target.value); // controlled input
setError(err); // user must see it now
formAction(data); // form submissionForm submissions, error states, and anything the user must see immediately stay urgent. Wrapping everything in startTransition makes nothing feel faster, it just hides which updates matter.
The whole pattern is one decision: which update is urgent. Split it, wrap the other one, wire up isPending. Typing goes back to feeling instant and the list catches up when it can.
React app with a janky search, tabs, or a list nobody wants to scroll? This fix is usually a day. Tell me about the screen and I will tell you which update to wrap first.