frontend / react / concurrent_rendering.md

Concurrent Rendering — useTransition, useDeferredValue, How Priorities Work

6 min read source

Concurrent Rendering — useTransition, useDeferredValue, How Priorities Work

TL;DR

React 18+ can interrupt rendering to keep the main thread responsive: high-priority updates (typing, clicking) preempt lower-priority ones (large list filter, route transitions). You opt into the deprioritization with useTransition (“this state update is non-urgent”) and useDeferredValue (“give me a lagged version of this value”). They’re INP-critical features — most app slowness comes from one heavy render blocking a fast interaction.

Interview Q&A

Q: What does “concurrent rendering” actually mean?

A: React’s reconciler can start, pause, and resume rendering work. Before React 18, render was synchronous start-to-finish; if it took 200ms, the main thread was blocked for 200ms. Concurrent rendering chunks the work and yields back to the browser between chunks if a higher-priority event arrives.

Implications:

  • An expensive setState doesn’t lock the UI.
  • The user can type/click during long renders without lag.
  • React picks up where it left off, or discards the in-progress render if it’s now stale.

Concurrency in React is opt-in via specific APIs (useTransition, useDeferredValue, <Suspense>) — it doesn’t suddenly make all renders interruptible by default.

Q: useTransition — what’s the API and when do you use it?

A:

import { useState, useTransition } from "react";

function Search() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Result[]>([]);
  const [isPending, startTransition] = useTransition();

  function onChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value);                              // urgent — input stays snappy

    startTransition(() => {
      setResults(computeResults(value));          // non-urgent — can be interrupted
    });
  }

  return (
    <>
      <input value={query} onChange={onChange} />
      {isPending && <Spinner />}
      <List items={results} />
    </>
  );
}

What happens:

  • setQuery runs immediately; the input feels responsive.
  • setResults inside startTransition is marked non-urgent.
  • If the user types again before setResults’s render finishes, React discards the in-progress render and starts fresh with the latest value.
  • isPending is true while the transition’s render is in progress.

Use for: filters, search, route transitions, expensive recomputes triggered by user input.

Q: useDeferredValue — what’s the difference?

A: useDeferredValue(value) returns a value that lags behind the source by a render or two — React renders the urgent change with the old value first, then re-renders with the new value when it has spare cycles.

function Search({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
  const results = useMemo(() => computeResults(deferredQuery), [deferredQuery]);
  const isStale = query !== deferredQuery;
  return <List items={results} className={isStale ? "stale" : ""} />;
}

The difference vs useTransition:

  • useTransition wraps a state setter — you opt in at the write site.
  • useDeferredValue wraps a value — you opt in at the read site.

Use useDeferredValue when you don’t control the state update (the value comes from props or a hook), or when you want to defer rendering based on a value without restructuring your setState calls.

Q: When are they interchangeable?

A: Often. For a typeahead controlled by your own state:

// Option A — useTransition
const [isPending, start] = useTransition();
function onChange(v: string) { setQuery(v); start(() => setResults(compute(v))); }

// Option B — useDeferredValue
const [query, setQuery] = useState("");
const deferred = useDeferredValue(query);
const results = useMemo(() => compute(deferred), [deferred]);

Both achieve the same effect — non-blocking heavy work, urgent input updates. useTransition gives you isPending; useDeferredValue gives you value !== deferredValue as the stale flag.

Q: What does “priority” mean inside React’s scheduler?

A: React internally tags updates with a priority level. Roughly (simplified):

Priority Examples
Sync / Discrete Click, keypress, form input updates
Continuous Scroll, hover, drag
Default setState outside event handlers
Transition startTransition-wrapped updates
Idle (internal) work that can wait

Higher-priority updates interrupt lower-priority renders. The scheduler yields to the browser between work chunks (~5ms slices) and checks if anything urgent is pending.

You don’t set priorities directly — React infers from API. useTransition is the public knob to demote an update.

Q: How does this connect to INP?

A: INP is the latency from user input → next paint. The bottleneck is usually:

  1. Event handler runs.
  2. React renders affected components. (← long if you don’t transition)
  3. Browser paints.

useTransition shrinks step 2 from the user’s POV: the urgent state (input value) renders fast; the expensive state (filtered list of 5000 items) renders deferred. INP measures the urgent paint, which is now fast.

Without useTransition, both updates batch into one render; the expensive one drags the urgent one along; INP suffers.

Q: Does useTransition work with setState from a non-React source (Redux, Zustand)?

A: Limited. useTransition only marks updates inside its startTransition callback as non-urgent. External stores dispatch synchronously and React schedules their re-renders at the default priority.

For external stores, the equivalent is useDeferredValue(useStore(selector)) — defer the value coming out, even if the store doesn’t know about React’s priorities.

Q: Common mistake — startTransition inside useEffect or async.

A: startTransition is a synchronous wrapper. Calling setState after an await doesn’t get the transition treatment automatically:

// Wrong — startTransition wraps only the await, not the setState after
async function search(q: string) {
  startTransition(async () => {
    const r = await fetch(...);    // awaits here
    setResults(r);                  // this setState is NOT in the transition
  });
}

// Right — wrap the setState specifically
async function search(q: string) {
  const r = await fetch(...);
  startTransition(() => setResults(r));
}

React 19 may relax this for async actions (Server Actions get transition-like behavior). For now, keep startTransition synchronous.

Q: When not to use useTransition?

A:

  • The render is already fast (<16ms). Don’t add it preemptively.
  • The update is genuinely urgent (the user expects immediate feedback). A confirmed-deletion flash should be sync.
  • The state must update atomically with another sync state — splitting via transitions can cause visible inconsistency.

Gotchas / edge cases

  • isPending flicker — for sub-100ms transitions, isPending flashes briefly. Hide the spinner if pending < 150ms (use a min-display library) or skip it.
  • Stale isPending after rapid changes — the latest transition’s isPending wins; the older one’s already discarded.
  • useDeferredValue initial render — returns the source value on the first render; lag starts from the second update.
  • Suspense + transition — a suspended boundary inside a transition shows the previous content (not the fallback) until the new one resolves. Great UX: no flash to fallback for navigation.
  • Concurrent rendering doesn’t make slow code fast — it lets fast code stay snappy during slow code. Long render is still long.
  • startTransition outside a component (e.g., in an external store) needs to be imported from React directly and called from a context that has access to the renderer; usually you useTransition inside components.

What a senior is expected to say

  • “Concurrent rendering lets React interrupt low-priority work for high-priority events — the lever for INP-sensitive UIs.”
  • useTransition at the write site (startTransition(() => setX(...))); useDeferredValue at the read site for a lagged value. Often interchangeable for typeahead-style flows.”
  • “The pattern: urgent input update fires immediately and re-renders the input; the expensive downstream update (filter, list render) is wrapped in startTransition so it doesn’t block the typing render.”
  • “Inside an await, startTransition doesn’t carry through — wrap the setState after the await.”
  • “Useful + visible UX win: Suspense inside a transition keeps the previous content visible while the new view loads (no flash to fallback).”

Cross-references

Further reading