Skip to content
Chapter 25Lesson 6

Marking updates as non-urgent

How 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 a list of results below it. It works fine with twenty rows. Then the list grows to five thousand, and the box turns to mud: you press a key, and there’s a beat before the letter shows up. Type fast and the input falls behind your fingers, painting characters in stutters.

The same feel shows up in three disguises. A tab bar freezes the page mid-animation because clicking a tab triggers a heavy render. A data table with a filter dropdown locks for a third of a second every time you change it. A search input lags. Different screens, but the same bug underneath.

Most developers reach for the same fix here: the render is too slow, so I need to make it faster. They add a debounce, memoize harder, or start virtualizing the list. Sometimes that helps, but it misdiagnoses the problem, and the misdiagnosis is worth understanding.

The real issue isn’t speed. It’s priority. When the user presses a key, two things need to happen: the input must show the new character right away, and the list must re-filter. Those two updates have very different urgency. The keystroke must land instantly, or the input feels broken. The re-filtering can wait a beat, because nobody minds if the results refresh a fraction of a second after they stop typing. The bug is that React, by default, treats both updates as equally urgent and does them together in one blocking pass. The slow list re-render holds the main thread, so the fast keystroke gets stuck behind it.

By the end of this lesson you’ll be able to 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 a given situation. You already watched React skip work it didn’t need to do, back in “State is a snapshot”, where the Object.is bailout let it skip a re-render whose state reference hadn’t changed. This lesson is the other half of that idea: not React skipping work, but React reordering it.

It helps to start with the model rather than the API, because the model is what tells you which API to use.

React 19’s renderer is concurrent . That word carries one specific power: React can start rendering an update, pause partway through if something more urgent comes in, deal with the urgent thing, and then come back to finish. It can also throw away the half-done work and start over. A render is no longer one all-or-nothing blocking pass; it’s interruptible.

That capability does nothing on its own, because React doesn’t know which of your updates are urgent. It can’t guess that the input matters more than the list, so you have to tell it. You mark certain state updates as a transition , which is React’s word for an update that is low-priority: render it in the background, and interrupt it freely if something urgent shows up. Everything you don’t mark stays urgent and renders first.

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

Once that lands, the rest is mechanics. Get it wrong and you’ll scatter these hooks around expecting a speed-up, find that 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 is that React will abandon work it already started. Step through the following diagram one frame at a time, and watch what happens at 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 arrives and 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, since 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.

The abandon-and-restart in step four is worth dwelling on. A transition can be interrupted, and that’s not a glitch: it’s correct. The half-rendered list for a was already stale, because the user had typed b, so throwing it out and restarting for ab is exactly what you’d want. 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, and transitions render in the background and yield. Next come the two ways to put an update in that background lane.

Reach for the first tool when you own the setter, meaning you’re the one writing the code that calls setSomething, so you get to decide right there that this update is non-urgent.

It’s a hook:

const [isPending, startTransition] = useTransition();

It hands you back two things. startTransition is a function, and anything you call inside the callback you pass it gets marked as a transition. isPending is a boolean that is true from the instant a transition starts until its background render commits. That flag is your connection to the UI: it’s how you dim a stale list or show a spinner while the slow render catches up.

Now wire up the search box properly. The key move is that one onChange fires two state updates with two different priorities. Walk through the following component step by step.

'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 is what 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>. Because it’s wrapped in startTransition, React marks it as a transition: it renders the result in the background and interrupts it 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 an opacity-50 class to the list to dim the stale results. One flag, your choice of affordance.

1 / 1

Notice the split: two pieces of state from one event. query and filter start out holding the same string, but they update on different priority lanes. setQuery is bare, so it’s urgent and the input repaints instantly. setFilter is wrapped, so it’s non-urgent: the heavy list re-render happens in the background, where the next keystroke can interrupt it instead of waiting behind it. The input is bound to query, never to filter, and that’s exactly why the input stays crisp while the list lags a beat behind.

A few things trip people up here, and each is worth naming at the spot where it bites.

The first is a tempting shortcut that quietly does nothing:

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

startTransition marks whatever state updates happen while its callback runs. Pass it setFilter directly, as the first line does, and you’ve handed it a function it never calls: no setter fires inside the transition, so nothing gets marked. You have to actually call the setter inside the callback, the way the second line does. This is the most common first mistake, so if your transition seems to have no effect, check this first.

The second is a mental model worth correcting before it sets. It’s tempting to think of a transition as a setTimeout that delays the update, but it isn’t. setFilter(value) still queues its commit immediately. The update is not postponed, not debounced, not deferred in time; only its priority changes. React still does the work right away, just in a lane that yields to urgent updates. A transition reorders work, it doesn’t delay it.

One convenience is worth knowing. isPending stays true not just for the transition’s render but also for any asynchronous resource that render kicks off. So if your background render triggers a data fetch (you’ll see that shape shortly), the same single flag keeps your spinner up through the whole thing. You don’t have to manage two loading states, because isPending covers both.

The second tool is the mirror image of the first. useTransition works when you own the setter, but sometimes you don’t, and the value just arrives. A third-party combobox calls you back with a query string. A router hook hands you the current URL search params. A parent passes a prop down. In all of these, there’s no setQuery of yours to wrap, because you’re not the one calling it. You only receive the value.

For that, you mark the value instead of the setter:

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

useDeferredValue takes a value and gives you back a version of it that lags. When query changes, deferredQuery doesn’t change with it, at least not at first. The urgent render runs with deferredQuery still holding the previous value, so whatever depends on it (your slow list) stays put for that pass. Then, in the background, React re-renders with deferredQuery caught up to the new value. The lag is the whole point: it’s how the cheap parts of your UI race ahead while the expensive part trails behind.

Picture the search box again, but this time the input’s value comes from somewhere you can’t wrap, say a router hook. You can’t intercept the setter, so you wrap at the consumer instead: 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. It’s the same outcome as the transition, reached from the other end of the data flow.

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

One trap here is common enough to be worth its own warning:

A smaller hazard to keep in mind: an effect that reads a deferred value sees the deferred (lagging) value, not the live one. That’s usually what you want, since the effect tracks the settled state, but it occasionally catches someone out.

Two hooks, but only one decision separates them:

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

Both produce the same downstream behavior: 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 right where it happens. If a value is handed to you and you can’t touch how it’s set, lag it at the point you read it.

Here’s the same search box solved both ways. The only thing that changes is where you intervene.

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 control the state update, so mark it where it happens. You own the onChange, so you split the update into urgent (setQuery) and non-urgent (setFilter) right at the source. isPending comes free for a spinner.

Notice that both tabs keep the input bound to the live query. That’s not a coincidence: it’s the rule that holds across both approaches. Whatever the user is directly manipulating always rides the urgent lane. The only question is whether you express that by wrapping a setter or by deferring a value, and that’s decided entirely by whether the setter is yours to wrap.

Try sorting a few scenarios with the question below.

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 state updates. React 19 lets you put async work inside one, and this turns out to be the foundation that much of the modern React data story is built on.

The shape is straightforward: you pass startTransition an async callback, and any work you await inside it stays part of the transition. The useful part is that isPending remains true until the whole asynchronous flow settles, so a single flag drives your loading affordance across the entire round trip, fetch and re-render alike.

There’s one sharp edge here that you have to get right, because the wrong shape silently drops the transition:

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

Look at the inner startTransition. It’s there because of how await works: once execution resumes after an await, it’s no longer synchronously inside the original startTransition call. React only marks updates that happen synchronously within the callback, so a bare setResults(data) after the await would run at normal urgent priority, which defeats the point. Wrapping the post-await setter in a fresh startTransition puts it back on the transition lane. React’s own docs flag this as a current limitation they intend to smooth over later; until then, re-wrap any setter that lives after an await.

This matters beyond the search box. Every Server Action you’ll write, meaning every <form action={...}> submission, is implicitly wrapped in a transition under the hood. That’s the whole reason those forms can expose a pending state: the submission is a transition, so React knows when it’s in flight. You won’t learn Server Actions here (a later unit covers them, along with the hooks that read their pending and optimistic state), but when you meet them you’ll recognize this same machinery.

The isPending flag is where all of this pays off in the UI. From that one boolean you can dim the stale list, show an inline spinner, or disable a submit button while the work runs. It’s the same affordance whether the transition is a synchronous filter or an async round trip.

Keeping the old UI on screen during a transition

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

There’s one more reason transitions feel as good as they do, and it shows up the moment a transition’s render isn’t ready right away, when it has to wait on something asynchronous.

Here’s the situation. A transition starts rendering the new UI, but that render needs data that hasn’t arrived yet, so it hits a Suspense boundary . Without a transition, React would tear down the current UI and drop in the fallback spinner, a jarring flash to nothing. With a transition, React does something better: it keeps the previously committed UI on screen, exactly as it was, until the new content is ready. The old results stay visible, you can dim them with isPending to signal they’re stale, and they swap to the fresh ones in one clean step. No flash to a spinner.

The distinction worth remembering is when each one is for:

You haven’t formally met <Suspense>, use(), or streaming yet; those land in the next lesson and a later chapter. For now you only need to hold one fact: a transition cooperates with Suspense to keep already-rendered UI on screen instead of flashing a fallback. That cooperation is a big part of why these feel smooth rather than just technically correct.

Reading about jank disappearing is nothing like feeling it. The exercise below gives you a real, genuinely laggy type-ahead. The slowness is not faked with a setTimeout; it’s a deliberate pile of work the browser actually has to grind through on every render. Right now the input itself stutters, because the same state drives both the input and the heavy list. Your job is to 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 solution here is useDeferredValue, which has the fewest moving parts when a single value drives the slow component.

    Reference solution

    One value drives the slow component, so useDeferredValue is the leanest fix. Keep the input on the live query; 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 hands you isPending for a spinner, at the cost of one more piece of state. Both are correct; the deferred-value version simply has fewer moving parts when a single value feeds the slow render.

    If you did it right, the change is striking. The input that stuttered now keeps pace with your fastest typing, and the list quietly catches up the instant you pause. Nothing got faster, since the list render still costs exactly what it did before. You just stopped letting it block the keystroke.

    You now have both tools, so the next thing to get right is when not to use them: the default is no transition. Reaching for one of these on an update that doesn’t need it adds indirection (an extra state, a pending flag, a mental note that this value lags) for no payoff. Wrapping every state update by default is an anti-pattern, not a safety measure. These are conditional tools, and three conditions have to hold together before you reach for one:

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

    The API invites four specific misreadings, so here’s what these hooks are explicitly not:

    • Not a debounce. A debounce delays when the work runs: it waits for a pause, then does the work. A transition runs the work immediately, just at low priority. The mechanism is entirely different, and you’ll meet debounce and throttle in a later chapter.
    • Not a way to make slow code fast. They reorder priority and do nothing else. The render costs what it costs.
    • Not a replacement for <Suspense>. Suspense fallbacks are for the first load of a region; transitions are for updating something already on screen.
    • Not relevant to non-React work. A slow network request or a blocking setTimeout isn’t reordered by these, because they only govern the priority of React renders. If your bottleneck is outside React, this isn’t your tool.

    There’s one last shape to recognize. Everything so far used the useTransition hook, which is convenient inside a component because it also hands you isPending. Occasionally, though, you need to mark an update as a transition from somewhere you can’t call a hook, like module scope or a utility function outside any component. 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 you’re outside a component and the hook isn’t available; inside a component, useTransition is the default because the pending flag is almost always worth having.

    This isn’t a niche optimization you’ll use once. It’s the machinery under every responsive search box, every snappy tab switch, and every filter that doesn’t lock the table. It’s also the same priority model that Server Action forms and a production URL-driven search input (a later unit rebuilds exactly that) are quietly built on. You’ll meet it constantly, and now you know what it’s actually doing: not making anything faster, but telling React what to render first.

    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.