Skip to content
Chapter 25Lesson 6

Marking updates as non-urgent

React 19's concurrent rendering hooks, useTransition and useDeferredValue, keep an interface responsive by letting heavy renders wait while urgent ones go first.

You’ve built a search box. The user types, and on each keystroke you filter the list of results below it. With twenty rows it’s fine. At five thousand the box turns to mud: you press a key, and there’s a beat before the letter appears. Type fast and the input falls behind your fingers, painting characters in stutters.

The same lag wears other disguises: a tab bar that freezes mid-animation because the click triggers a heavy render, a data table that locks for a third of a second on every filter change. Same bug underneath.

Most developers read this as a speed problem: the render is too slow, so make it faster. They add a debounce, memoize harder, or virtualize the list. Sometimes that helps, but it misdiagnoses the cause.

The real issue isn’t speed. It’s priority. One keystroke fires two updates: the input must show the new character, and the list must re-filter. The keystroke has to land instantly or the input feels broken; the re-filter can wait a beat, since nobody minds if results refresh a moment after they stop typing. But React, by default, treats both updates as equally urgent and runs them together in one blocking pass, so the slow re-render holds the main thread and the keystroke gets stuck behind it.

By the end of this lesson you’ll keep an interface responsive under a heavy render by telling React which updates are allowed to wait, and you’ll know which of two tools to reach for. In “State is a snapshot” you watched React skip work it didn’t need to do. This lesson is the other half of that idea: not skipping work, but reordering it.

Start with the model, because the model tells you which API to use.

React 19’s renderer is concurrent . It can start an update, pause partway if something more urgent arrives, handle that, then resume, or throw the half-done work away and start over. A render is no longer all-or-nothing; it’s interruptible.

That buys nothing on its own, because React can’t tell which of your updates are urgent. It won’t guess that the input matters more than the list, so you tell it: mark a state update as a transition and React renders it in the background, interrupting it freely when something urgent shows up. Anything you don’t mark stays urgent and renders first.

The next idea is the core of the lesson, and the one people get wrong most often:

Get this wrong and you’ll scatter these hooks around expecting a speed-up, find nothing got faster, and end up with slower code that has more moving parts.

So what does “interrupt and resume” look like as the user types? The surprising part: React will abandon work it already started. Step through the diagram one frame at a time, and watch step four, when a second keystroke arrives before the first one’s background render has finished.

Urgent the <input>
queued set input → a
Transition the <SlowList>
queued filter → a
user
types atypes bstops
The user types `a`. Two updates queue: an urgent one (set the input's value) and a transition (re-filter the list). Nothing has run yet; both are sitting in line.
Urgent the <input>
committed input = a
Transition the <SlowList>
rendering filter → a
user
types atypes bstops
React commits the urgent update immediately. The input shows `a`. Only now does the background render for the list begin. The striped block is work in flight, not yet on screen.
Urgent the <input>
committed input = a
queued set input → b
Transition the <SlowList>
rendering filter → a
user
types atypes bstops
Before that background render finishes, the user types `b`. A new urgent update joins the queue while the list is still rendering for `a`.
Urgent the <input>
committed input = a
committed input = b
Transition the <SlowList>
discarded filter → a
rendering filter → ab
user
types atypes bstops

React interrupts. It throws away the half-finished list render for a, commits b to the input instantly, and restarts the list render for ab. The discarded work was about to be wrong anyway: it was filtering for a query the user had already moved past.

Urgent the <input>
committed input = ab
Transition the <SlowList>
committed list = ab
user
types atypes bstops
Typing stops. The background render for `ab` runs to completion and the list updates. Through all of it, the input never stuttered once.

Interrupting a transition isn’t a glitch, it’s correct. The half-rendered list for a was already stale the moment the user typed b, so discarding it and restarting for ab is exactly right. React assumes a transition’s output might be obsolete before it finishes, which is why it never lets that work block anything urgent and never hesitates to discard it.

That’s the whole model: two priorities, urgent updates commit first, transitions render in the background and yield. Next come the two ways to put an update in that background lane.

Reach for useTransition when you own the setter: you write the code that calls setSomething, so you can mark that update non-urgent on the spot.

It’s a hook:

const [isPending, startTransition] = useTransition();

It returns two things. startTransition is a function; anything you call inside its callback is marked as a transition. isPending is a boolean, true from the moment a transition starts until its background render commits. That flag is your hook into the UI: use it to dim a stale list or show a spinner while the slow render catches up.

Now wire up the search box. The key move: one onChange fires two state updates at two priorities.

'use client';
export function ProductSearch() {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
startTransition(() => {
setFilter(event.target.value);
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<SlowList query={filter} />
</div>
);
}

The hook returns the pending flag and the marker. It takes no arguments.

'use client';
export function ProductSearch() {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
startTransition(() => {
setFilter(event.target.value);
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<SlowList query={filter} />
</div>
);
}

The urgent update. query drives the controlled <input>, so this paints the keystroke. It runs outside the transition, at normal priority, because the input must never lag behind the user’s fingers.

'use client';
export function ProductSearch() {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
startTransition(() => {
setFilter(event.target.value);
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<SlowList query={filter} />
</div>
);
}

The non-urgent update. filter drives the expensive <SlowList>. Wrapped in startTransition, React renders it in the background and interrupts that render for any keystroke. Note that you call setFilter inside the callback, which is what makes the difference.

'use client';
export function ProductSearch() {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
startTransition(() => {
setFilter(event.target.value);
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<SlowList query={filter} />
</div>
);
}

While the background render is in flight, isPending is true, so a spinner shows. The same boolean could instead add opacity-50 to dim the stale list. One flag, your choice of affordance.

1 / 1

query and filter start out holding the same string but update on different lanes. setQuery is bare, so it’s urgent and the input repaints instantly. setFilter is wrapped, so it’s non-urgent: the heavy re-render runs in the background, where the next keystroke can interrupt it instead of waiting behind it. The input is bound to query, never filter, which is why it stays crisp while the list lags a beat behind.

Three things trip people up. Each is worth naming at the spot where it bites.

First, a tempting shortcut that quietly does nothing:

startTransition(setFilter);
startTransition(() => setFilter(value));

startTransition marks whatever updates run while its callback runs. Pass it setFilter directly and you’ve handed it a function it never calls, so nothing gets marked. You have to call the setter inside the callback. This is the most common first mistake, so if a transition seems to have no effect, check here first.

Second, a mental model worth correcting early. A transition is not a setTimeout that delays the update. setFilter(value) still queues its commit immediately; only its priority changes. React does the work right away, just in a lane that yields to urgent updates. A transition reorders work, it doesn’t postpone it.

Third, a convenience. isPending stays true not only for the transition’s render but for any async resource that render kicks off. So if the background render triggers a data fetch (you’ll see that shape shortly), the same flag keeps your spinner up through the whole thing, with no second loading state to manage.

The second tool is the mirror image of the first. useTransition works when you own the setter, but sometimes the value just arrives: a third-party combobox calls you back with a query string, a router hook hands you the URL search params, a parent passes a prop down. There’s no setter of yours to wrap, because you only receive the value.

So you mark the value instead of the setter:

const deferredQuery = useDeferredValue(query);
return <SlowList query={deferredQuery} />;

useDeferredValue takes a value and returns a version of it that lags. When query changes, deferredQuery doesn’t change with it. The urgent render runs with deferredQuery still holding the previous value, so the slow list that depends on it stays put for that pass; then React re-renders in the background with deferredQuery caught up. That lag is the mechanism: the cheap parts of your UI race ahead while the expensive part trails behind.

Here you wrap at the consumer rather than the setter. The input stays bound to the live query, which is urgent and instant, and only SlowList reads deferredQuery. The input updates the moment the user types, and the list catches up a beat later.

There’s a compounding win when the deferred value feeds a memoized computation. If SlowList filters inside a useMemo keyed on the query, that expensive filter re-runs only when the deferred value changes, once per settle rather than once per keystroke. React’s compiler memoizes pure computation automatically, so you often get this for free; useMemo is the manual fallback for cases it can’t infer. Memoization gets its own chapter later; for now, note that deferring a value and memoizing on it stack neatly.

One more thing to know: an effect that reads a deferred value sees the deferred, lagging value, not the live one. That is usually what you want, since the effect tracks the settled state.

Two hooks, one decision tells them apart:

Own the setter → useTransition. Only receive the value → useDeferredValue.

Both produce the same result: the expensive render runs at low priority while the urgent path stays responsive. They differ only in which side of the data flow you grab. If you control the code that calls setX, mark the update there. If a value is handed to you and you can’t touch how it’s set, lag it where you read it.

Here’s the same search box solved both ways. Only the point of intervention changes.

const [query, setQuery] = useState('');
const [filter, setFilter] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
startTransition(() => setFilter(event.target.value));
};
return (
<>
<input value={query} onChange={handleChange} />
<SlowList query={filter} />
</>
);

You own the onChange, so you split the update at the source: urgent setQuery, non-urgent setFilter. isPending comes free for a spinner.

Both tabs keep the input bound to the live query. Whatever the user manipulates directly always rides the urgent lane; the only choice is whether you express that by wrapping a setter or by deferring a value, decided by whether the setter is yours.

In which of these would you reach for useDeferredValue rather than useTransition? Select all that apply.

A third-party <Combobox> fires onValueChange with the new query; a heavy results panel below it renders from that query.
Your own <input>’s onChange calls setFilter with the typed text, and a big list renders from filter.
A useSearchParams-style router hook returns the live ?q= value, and a slow table renders straight from what the hook hands back.
A button’s onClick calls setSelectedTab, and switching tabs kicks off an expensive re-render.

Transitions aren’t limited to synchronous updates. React 19 lets you put async work inside one: you pass startTransition an async callback, and any work you await stays part of the transition. isPending stays true until the whole flow settles, so one flag drives your loading affordance across the entire round trip, fetch and re-render alike. From that single boolean you can dim the stale list, show an inline spinner, or disable the submit button while the work runs, exactly as with a synchronous transition.

One sharp edge will silently drop the transition if you get it wrong:

startTransition(async () => {
const data = await fetchSomething(query);
startTransition(() => {
setResults(data);
});
});

The inner startTransition is there because of how await works: once execution resumes after the await, it’s no longer synchronously inside the original callback. React only marks updates that run synchronously within the callback, so a bare setResults(data) would run at normal urgent priority and defeat the purpose. Re-wrapping it puts the update back on the transition lane. React’s docs flag this as a current limitation, so until it’s smoothed over, re-wrap any setter that lives after an await.

This matters beyond a search box. Every Server Action you write, meaning every <form action={...}> submission, is implicitly wrapped in a transition. That’s what lets those forms expose a pending state at all: the submission is a transition, so React knows when it’s in flight. A later unit covers Server Actions and the hooks that read their pending and optimistic state; when you meet them, you’ll recognize this same machinery.

Keeping the old UI on screen during a transition

Section titled “Keeping the old UI on screen during a transition”

Transitions have one more advantage, and it shows up the moment a transition’s render needs data that hasn’t arrived yet.

The new UI starts rendering, but it depends on data that isn’t ready, so it hits a Suspense boundary . Without a transition, React tears down the current UI and drops in the fallback spinner, a jarring flash to nothing. With a transition, React keeps the previously committed UI on screen until the new content is ready. The old results stay visible, you can dim them with isPending to mark them as stale, and they swap to the fresh ones in one step.

The two tools are for different moments:

You’ll meet <Suspense>, use(), and streaming in the next lesson and a later chapter. For now, hold one fact: a transition cooperates with Suspense to keep already-rendered UI on screen instead of flashing a fallback.

Reading about disappearing jank is nothing like feeling it go. The exercise below hands you a real, laggy type-ahead. The slowness is not a faked setTimeout; it’s actual work the browser grinds through on every render. Right now the input itself stutters, because one piece of state drives both the input and the heavy list. Split that update so the input stays instant while the list lags behind.

This type-ahead lags on every keystroke because one piece of state drives both the input and a heavy list. Keep the input updating instantly while letting the list fall a beat behind. Reach for the hook that fits — you own the input here, but either tool can work; pick one and split the update so the input never waits on the list. The tests pass the moment the input rides ahead of the list.

Preview LIVE

    The canonical fix is useDeferredValue: the fewest moving parts when a single value drives the slow component.

    Reference solution

    Keep the input on the live query, and hand SlowList the lagging copy.

    import { useDeferredValue, useState } from 'react';
    export function App() {
    const [query, setQuery] = useState('');
    const deferredQuery = useDeferredValue(query);
    return (
    <div className="p-4">
    <input
    value={query}
    onChange={(event) => setQuery(event.target.value)}
    placeholder="Filter 5,000 items…"
    className="w-full rounded border px-3 py-2"
    />
    <SlowList query={deferredQuery} />
    </div>
    );
    }

    Since you own the onChange here, useTransition works just as well: add a second filter state and call startTransition(() => setFilter(event.target.value)) alongside the urgent setQuery, then render <SlowList query={filter} />. That route also gives you isPending for a spinner, at the cost of one more piece of state.

    Done right, the input keeps pace with your fastest typing, and the list catches up the moment you pause. Nothing got faster; the list render still costs exactly what it did. You just stopped letting it block the keystroke.

    The default is no transition. Wrapping an update that doesn’t need it buys you nothing and adds indirection: an extra state, a pending flag, a value that now lags. Three conditions have to hold together before a transition is worth it:

    1. There’s measurable jank. The user actually feels the stutter. A render that blocks the main thread past roughly 50 milliseconds is where it starts to read as lag. If the interaction is already smooth, do nothing.
    2. The work is genuinely large. Filtering thousands of rows, sorting a big dataset, a heavy visualization. A twenty-item list re-renders in well under a millisecond, so wrapping it is pure ceremony.
    3. The compiler hasn’t already erased it. React’s compiler memoizes pure computation automatically, so the render you’re worried about may already be cheap. Measure before you assume there’s a problem to solve.

    The API invites four misreadings, so here is what these hooks are not:

    • Not a debounce. A debounce delays when the work runs, waiting for a pause before doing it. A transition runs the work immediately, just at low priority.
    • Not a way to make slow code fast. They reorder priority and nothing else. The render costs what it costs.
    • Not a replacement for <Suspense>. Suspense fallbacks are for the first load of a region; transitions update something already on screen.
    • Not relevant to non-React work. A slow network request or a blocking setTimeout is untouched, because these only govern the priority of React renders. If your bottleneck is outside React, this isn’t your tool.

    One last shape to recognize. useTransition is convenient inside a component because it also hands you isPending. But sometimes you need to mark a transition from where you can’t call a hook, like module scope or a utility function. For that, React exports startTransition as a standalone function:

    import { startTransition } from 'react';
    startTransition(() => {
    setFilter(value);
    });

    It does the same priority marking, just without isPending. Reach for it only when the hook isn’t available; inside a component, useTransition is the default, since the pending flag is almost always worth having.

    The React reference docs go deeper on the edge cases this lesson skipped: the full signatures, the SSR initialValue argument, and a gallery of patterns for each hook.