Topics
v5.0.0
On this page · sections

Concurrent React

Concurrent React lets React interrupt, defer, and prioritize work so urgent updates (typing, clicking) stay responsive while expensive renders catch up. useTransition and useDeferredValue are the primary APIs for marking updates as non-urgent.

APIWho owns the updateReturnsControls
useTransitionYou dispatch the update[isPending, startTransition]Render priority
useDeferredValueYou receive the valueLagging copy of valueConsumption priority
DebounceEffect timingDelayed valueWhen effects fire (ms)
ThrottleEffect rateCapped valueMax effect frequency

useTransition

Definition

useTransition lets you mark state updates as transitions — lower priority, interruptible renders.

'use client'; import { useMemo, useState, useTransition } from 'react'; function SearchPage() { const [text, setText] = useState(''); const [query, setQuery] = useState(''); const [isPending, startTransition] = useTransition(); function handleChange(event: React.ChangeEvent<HTMLInputElement>) { const value = event.target.value; setText(value); // urgent — input startTransition(() => setQuery(value)); // non-urgent — list } return ( <> <input value={text} onChange={handleChange} aria-busy={isPending} /> <div style={{ opacity: isPending ? 0.6 : 1 }}> <Results query={query} /> </div> </> ); } function Results({ query }: { query: string }) { const items = useMemo( () => ALL_ITEMS.filter((item) => item.includes(query)), [query], ); return ( <ul> {items.slice(0, 100).map((item) => ( <li key={item}>{item}</li> ))} </ul> ); }

Both updates come from the same event — split one interaction into urgent and deferrable parts.

isPending

isPending is true from when the transition is scheduled until its render commits. It is a scheduler flag, not a network loading state.

In React 19, startTransition accepts an async function — isPending stays true across await for Actions.

Interview: A transition is priority, not delay. No timer. React starts work immediately at a lower lane.

useDeferredValue

Definition

useDeferredValue returns a lagging copy of a value that React updates in a transition of its own.

'use client'; import { memo, useDeferredValue, useMemo, useState } from 'react'; const HeavyPreview = memo(function HeavyPreview({ text }: { text: string }) { const blocks = useMemo( () => Array.from({ length: 5000 }, (_, i) => `${text}-${i}`), [text], ); return <p>{blocks.length} blocks for current draft</p>; }); function Editor({ value, onChange }: { value: string; onChange: (next: string) => void; }) { const deferred = useDeferredValue(value); const isStale = deferred !== value; return ( <> <textarea value={value} onChange={(e) => onChange(e.target.value)} /> <div aria-busy={isStale} style={{ opacity: isStale ? 0.7 : 1 }}> <HeavyPreview text={deferred} /> </div> </> ); }

Use when you receive the value — from props, context, or a router — and do not own the setter. memo on the expensive child is required — otherwise the deferred value saves nothing.

In React 19, an optional initial value lets the first render show something cheap: useDeferredValue(value, '').

Interview: useTransition = I dispatch. useDeferredValue = I consume. Same scheduler underneath.

Transition vs Debounce

Definition

They operate on different resources:

debounce / throttle → controls how often an effect fires (milliseconds) transition / deferred → controls render priority (scheduler lanes)
'use client'; import { useEffect, useState, useTransition } from 'react'; function useDebouncedValue<T>(value: T, delayMs: number) { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delayMs); return () => clearTimeout(id); }, [value, delayMs]); return debounced; } function Typeahead() { const [text, setText] = useState(''); const [filter, setFilter] = useState(''); const [isPending, startTransition] = useTransition(); const debouncedQuery = useDebouncedValue(text, 300); useEffect(() => { if (!debouncedQuery) return; const controller = new AbortController(); fetch(`/api/suggest?q=${debouncedQuery}`, { signal: controller.signal }); return () => controller.abort(); }, [debouncedQuery]); return ( <input value={text} onChange={(e) => { setText(e.target.value); startTransition(() => setFilter(e.target.value)); }} aria-busy={isPending} /> ); }

Debounce protects the network. Transition protects the main thread.

Interview: Transitions do not reduce API calls. Debouncing, caching, and deduplication do.

Transitions and Suspense

Definition

When a component suspends inside a transition, React keeps the previous committed content visible instead of showing the fallback.

'use client'; import { Suspense, use, useState, useTransition } from 'react'; function Tabs() { const [tab, setTab] = useState('1'); const [isPending, startTransition] = useTransition(); function select(next: string) { startTransition(() => setTab(next)); } return ( <> <button type="button" onClick={() => select('1')}>Tab 1</button> <button type="button" onClick={() => select('2')}>Tab 2</button> <div aria-busy={isPending} style={{ opacity: isPending ? 0.6 : 1 }}> <Suspense fallback={<p>Loading…</p>}> <TicketView ticketPromise={getTicket(tab)} /> </Suspense> </div> </> ); } function TicketView({ ticketPromise }: { ticketPromise: Promise<Ticket> }) { const ticket = use(ticketPromise); return <h2>{ticket.title}</h2>; }

First render still shows fallback — there is nothing committed to keep. Subsequent tab switches preserve stale UI. Promises must be cached by id — a new promise every render suspends forever.

Interview: Suspense handles pending. Error Boundary handles rejection. Transition keeps stale UI during pending.

What Never Gets Deferred

Definition

Anything the user must see on the same frame as their input:

transition → filter results, tab content, route body, chart re-layout sync → input value, checkbox state, focus, immediate measurement

Deferring controlled input values produces dropped keystrokes.

flushSync is the opposite escape hatch — forces synchronous commit for DOM measurement:

import { flushSync } from 'react-dom'; flushSync(() => setExpanded(true)); panelRef.current?.scrollIntoView();

Use sparingly. Never pair flushSync with a transition for the same state. Standalone startTransition imported from react works outside components — useful for store setters.

Interview: Reduce work first (virtualize, memoize). Transitions manage latency for work that remains.

Senior Interview Summary

What to say

useTransition marks updates you dispatch as non-urgent. useDeferredValue returns a lagging copy of a value you receive. Both use the concurrent scheduler — priority, not delay. Pair with memo on expensive children and debounce for network protection.

Best mental model

useTransition → "This update can wait." useDeferredValue → "I'll read the old value until React catches up." debounce → "Wait 300ms of silence before firing the effect."

Practical rule

Own the setter → useTransition in event handler Receive value as prop → useDeferredValue + memo on expensive child API calls per keystroke → debounce the fetch key, not the render Tab switch with fetch → startTransition + cached promise + Suspense Controlled input → never defer the input state itself 12k DOM nodes → virtualize first, then transition

Last updated on