Deepen PracticeOrdered learning track

React Concurrent Rendering Mental Model

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 078

Deep production guide to React concurrent rendering mental model: interruptible render, urgent vs transition updates, Suspense boundaries, snapshots, effects, purity, scheduling, and concurrency failure modes.

10 min read1924 words
PrevNext
Lesson 78123 lesson track68–101 Deepen Practice
#react#hooks#concurrent-rendering#use-transition+4 more

Part 078 — React Concurrent Rendering Mental Model

Concurrent rendering is not “React uses multiple threads”.

Concurrent rendering means React can prepare UI work in a way that is interruptible, prioritized, and discardable before commit.

That one sentence changes how you design components.

In the old simplified model, a render starts and the browser waits until React finishes. In the concurrent model, some render work can be started, paused, abandoned, restarted, or superseded by a more urgent update.

If your components are pure, this is powerful.

If your render logic mutates external state, reads unstable mutable data, starts effects during render, or assumes render always commits, concurrent rendering exposes the bug.

This part builds the mental model needed before using useTransition, useDeferredValue, Suspense, streaming, React 19 Actions, or performance-oriented orchestration.


1. The Core Model

React has two broad phases:

render phase  ->  commit phase

Render phase:

calculate what the UI should look like
call components
produce a tree description
must be pure
can be interrupted/discarded

Commit phase:

apply changes to the host environment
update DOM
attach refs
run layout effects
then browser paints
then passive effects run

A simple diagram:

The advanced constraint:

Render may happen without commit.
Commit happens only for the winning render.

Therefore, render must not contain observable side effects.


2. What Concurrent Rendering Actually Enables

Concurrent rendering gives React room to prioritize user experience.

Example:

User types into input.
Input text update is urgent.
Large filtered result list update is non-urgent.
React should keep typing responsive while preparing the list in the background.

Without concurrency:

input update + expensive list render block together

With transition:

input update commits urgently
list update renders as interruptible background work

This is not magic. It is scheduling.


3. Urgent Update vs Transition Update

Not all updates have the same UX priority.

Update typeExampleShould block interaction?
Urgenttyping in input, clicking checkbox, dragging slideryes, must feel immediate
Transitionfilter large result list, navigate to tab content, show search resultsno, can be prepared in background
Background server refreshstale query refetchno, should avoid jank
Layout-criticalmeasure and place tooltipyes, but keep tiny

React's transition APIs let you mark updates as non-urgent.

const [isPending, startTransition] = useTransition();

function handleSearchChange(nextText: string) {
  setInputText(nextText); // urgent

  startTransition(() => {
    setSearchQuery(nextText); // non-urgent
  });
}

Important rule

The value controlled by an input should update urgently.
The expensive UI derived from that value may update in a transition.

Bad:

startTransition(() => {
  setInputText(event.target.value);
});

This can make the input feel laggy because the controlled value itself is no longer urgent.

Good:

setInputText(nextText);
startTransition(() => setFilter(nextText));

4. Concurrent Rendering Is Not Parallel DOM Mutation

A common misconception:

Concurrent React means two versions of the DOM update at the same time.

No.

The DOM commit is still coherent. React may prepare work in the background, but the committed UI is applied in a controlled commit phase.

The risk is not “half DOM”.

The risk is your code assuming that every render becomes visible.

Bad render side effect:

function InvoiceRow({ invoice }: { invoice: Invoice }) {
  analytics.track('invoice_row_rendered', { id: invoice.id });
  return <tr>{invoice.number}</tr>;
}

In concurrent rendering, this may track rows that never commit.

Better:

function InvoiceRow({ invoice }: { invoice: Invoice }) {
  useEffect(() => {
    analytics.track('invoice_row_visible', { id: invoice.id });
  }, [invoice.id]);

  return <tr>{invoice.number}</tr>;
}

Even this should be reviewed: visibility analytics for large lists may be better handled with IntersectionObserver, not per-row mount effects.


5. Render Purity Is a Concurrency Contract

React components must be pure with respect to props, state, and context.

Pure component logic:

function Total({ items }: { items: Item[] }) {
  const total = items.reduce((sum, item) => sum + item.price, 0);
  return <span>{formatCurrency(total)}</span>;
}

Impure render logic:

const renderedIds: string[] = [];

function Row({ item }: { item: Item }) {
  renderedIds.push(item.id);
  return <div>{item.name}</div>;
}

Why this breaks:

React may call Row during a render that is later discarded.
React may call Row again.
Strict Mode may double invoke in development to expose impurities.
The global array now contains entries that do not correspond to committed UI.

Rule

If it changes something outside the component's returned JSX, it probably does not belong in render.

Allowed in render:

pure calculations
object/array mapping without mutation of inputs
creating JSX
reading props/state/context
throwing Promise/Error for supported Suspense/Error Boundary mechanisms

Not allowed in render:

network request side effect
analytics side effect
global mutation
DOM mutation
subscription
setTimeout
imperative store write
random id used as stable identity
Date.now used as persistent UI fact

6. Snapshot Model Under Concurrency

State is still a snapshot.

Each render sees one coherent snapshot of props, state, and context.

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }

  return <button onClick={handleClick}>{count}</button>;
}

This increments once because each count + 1 uses the same render snapshot.

Correct when update depends on previous state:

setCount((current) => current + 1);
setCount((current) => current + 1);
setCount((current) => current + 1);

Under concurrent rendering, this model becomes more important because a background render can be superseded by a newer update. Never use stale closure values for transition-critical state transitions.

Rule

If the next value depends on the previous value, use an updater function or reducer event.

7. Timeline: Urgent + Transition Updates

Suppose a user types into a search box that filters a huge table.

The important behavior:

Older background work can be abandoned.
The user should see the latest committed consistent UI.

This is why render must be discardable.


8. useTransition Mental Model

useTransition returns:

const [isPending, startTransition] = useTransition();
  • startTransition marks state updates inside it as transition updates.
  • isPending tells you whether a transition is pending.
  • Transition renders are interruptible.
  • Urgent updates can interrupt transition work.

Example: search UI

function CaseSearchPage() {
  const [input, setInput] = useState('');
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const next = event.target.value;

    setInput(next);

    startTransition(() => {
      setQuery(next);
    });
  }

  return (
    <section>
      <input value={input} onChange={handleChange} />
      {isPending ? <span>Updating results...</span> : null}
      <CaseResults query={query} />
    </section>
  );
}

What this does not do

It does not make expensive JavaScript free.

If CaseResults does CPU-heavy work every render, transition can keep urgent updates responsive, but the expensive work still happens. You may still need:

virtualization
server-side search
memoized selectors
pagination
debounce for network pressure
web worker for CPU-heavy work

Rule

Transitions improve scheduling. They do not replace architecture.

9. startTransition Outside Components

startTransition can be imported directly.

import { startTransition } from 'react';

function externalStoreListener(nextState: StoreState) {
  startTransition(() => {
    store.setReactVisibleState(nextState);
  });
}

Use this only when you have a real scheduling boundary outside component code, such as a router, store, or data library integration.

Risk

If you wrap everything in startTransition, you destroy priority semantics.

Urgent updates should stay urgent.
Non-urgent visual preparation can be transitional.

10. useDeferredValue Mental Model

useDeferredValue lets a value lag behind while React prepares expensive UI.

const deferredQuery = useDeferredValue(query);

Then render expensive results from deferredQuery.

<SearchInput value={query} onChange={setQuery} />
<CaseResults query={deferredQuery} />

Difference from useTransition

APIYou controlBest for
useTransitionwhich update is non-urgentevent handler command flow
useDeferredValuewhich value can lagexpensive child rendering from changing value

Stale indicator

const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;

return (
  <div style={{ opacity: isStale ? 0.6 : 1 }}>
    <CaseResults query={deferredQuery} />
  </div>
);

Do not hide that results are stale if the domain requires immediate trust.

For regulatory or case-management UI, stale result indicators may matter. A search result list that lags by 300ms is fine. A permission decision that lags silently may not be fine.


11. Suspense as Async Boundary

Suspense is not a spinner component.

Suspense is a boundary that says:

If this subtree cannot complete rendering yet, show this fallback while React waits for supported async work.
<Suspense fallback={<CaseDetailSkeleton />}>
  <CaseDetail caseId={caseId} />
</Suspense>

Boundary placement matters

Bad:

<Suspense fallback={<FullPageSpinner />}>
  <EntireApplication />
</Suspense>

This creates huge blanking regions.

Better:

<PageShell>
  <CaseHeader caseId={caseId} />

  <Suspense fallback={<CaseTimelineSkeleton />}>
    <CaseTimeline caseId={caseId} />
  </Suspense>

  <Suspense fallback={<RelatedCasesSkeleton />}>
    <RelatedCases caseId={caseId} />
  </Suspense>
</PageShell>

Suspense and transitions

A transition can avoid hiding already-visible UI with a fallback too aggressively. This matters for navigation and tab switching.

startTransition(() => {
  setSelectedTab('history');
});

Instead of immediately blanking the old tab, React can keep the previous UI while preparing the new one, depending on boundary placement and data source integration.

Rule

Suspense boundary design is loading architecture.

12. React 19 Async Actions and Transitions

React 19 adds support for async functions in transitions and form Actions.

Conceptually:

const [isPending, startTransition] = useTransition();

function handleSubmit() {
  startTransition(async () => {
    await saveCaseDraft();
    setMode('review');
  });
}

This lets pending state cover the async action flow more naturally than manually juggling local flags everywhere.

But it does not remove the need for command correctness.

You still need:

idempotency for duplicate command risk
error taxonomy
conflict handling
cache invalidation
permission enforcement
rollback/reconciliation for optimistic UI

Rule

React Actions improve UI orchestration. They do not define your domain command semantics.

13. Effects Under Concurrent Rendering

Effects run after commit. Render may be discarded. Therefore, effects are where external synchronization belongs.

But effects also have failure modes.

Effect lifecycle

render winning tree
commit DOM
run cleanup for changed old effects
run setup for new effects

Example:

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();

  return () => {
    connection.disconnect();
  };
}, [roomId]);

This says:

For committed UI with roomId X, synchronize connection to X.
When committed roomId changes or component unmounts, disconnect previous X.

Failure: missing dependency

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
  return () => connection.disconnect();
}, []);

This creates a stale synchronization contract. The component may render room B while the effect remains connected to room A.

Failure: render-time subscription

const connection = createConnection(roomId);
connection.connect();

This can run during discarded render and leak.

Rule

Effects synchronize committed UI with external systems. They are not render calculations and not event handlers.

14. Layout Effects and Concurrent UX

useLayoutEffect runs after DOM mutation but before the browser repaints.

This is necessary for certain layout reads/writes:

useLayoutEffect(() => {
  const rect = ref.current!.getBoundingClientRect();
  setPosition(computeTooltipPosition(rect));
}, []);

But because it blocks paint, it can erase the benefit of concurrent scheduling if overused.

Failure modes

large layout effect blocks visible update
layout effect sets state repeatedly
measuring many rows individually
forced synchronous layout thrashing
using layout effect for work that can happen after paint

Rule

Concurrent rendering helps before commit. A heavy layout effect can still block the user at commit time.

Keep layout effects small, localized, and measurable.


15. External Store Consistency

External stores must integrate with React's concurrent model correctly.

React provides useSyncExternalStore for this reason.

Bad pattern:

function useStoreValue() {
  const [value, setValue] = useState(store.getState());

  useEffect(() => {
    return store.subscribe(() => setValue(store.getState()));
  }, []);

  return value;
}

This may work in simple cases, but it does not express the snapshot contract as directly as useSyncExternalStore.

Better:

function useStoreValue() {
  return useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    store.getServerSnapshot,
  );
}

Snapshot rule

getSnapshot must return the same value if the store has not changed.

Bad:

getSnapshot() {
  return { ...state };
}

This returns a new object every call and can cause infinite rerender loops or unnecessary renders.

Good:

let currentSnapshot = state;

function setState(next: State) {
  currentSnapshot = next;
  listeners.forEach((listener) => listener());
}

function getSnapshot() {
  return currentSnapshot;
}

Rule

Concurrent-safe external stores are snapshot systems, not random mutable globals.

16. Strict Mode as a Bug Finder

Strict Mode in development can double-invoke certain functions to expose accidental impurities.

Do not treat every Strict Mode issue as “React being annoying”.

Strict Mode often reveals bugs that concurrent rendering would make observable:

side effect in render
missing cleanup
non-idempotent setup
mutating props
unstable ref initialization
subscription leak
reducer impurity

Example leak

useEffect(() => {
  eventBus.subscribe('case.changed', handler);
}, []);

Missing cleanup.

Correct:

useEffect(() => {
  return eventBus.subscribe('case.changed', handler);
}, [handler]);

Rule

If Strict Mode exposes a duplicated effect, the fix is usually cleanup/idempotency, not disabling Strict Mode.

17. CPU Work and Scheduling

Concurrent rendering can interrupt React rendering work. It does not make arbitrary CPU work disappear.

Bad:

function CaseResults({ cases }: { cases: Case[] }) {
  const scored = expensiveScoreAndSort(cases);
  return <Rows cases={scored} />;
}

If this is expensive, options include:

server-side sorting/filtering
pagination
virtualization
memoized selector keyed by stable inputs
web worker
precomputed read model
transition/deferred rendering

Decision table

ProblemBetter tool
many DOM nodesvirtualization
expensive pure derived valuememoized selector
expensive backend-searchable computationserver query
CPU-heavy client-only algorithmweb worker
non-urgent visual updatetransition/deferred value
repeated network searchdebounce + cache

Rule

Scheduling is not a substitute for reducing work.

18. Concurrency and Component Identity

Concurrent rendering does not change React's state preservation rules:

same component type + same position/key = state preserved
changed type/key/position = state reset

But concurrent rendering can make accidental remounts more visible because transitions may preserve previous UI while preparing next UI.

Bad unstable key

<CaseEditor key={Math.random()} caseId={caseId} />

Every render resets the editor.

Correct reset key

<CaseEditor key={caseId} caseId={caseId} />

This intentionally resets when entity identity changes.

Rule

Keys are identity contracts. Under concurrent rendering, identity bugs become UX continuity bugs.

19. Concurrency and Forms

Form state has urgent and non-urgent layers.

Urgent:

input value
cursor position
focus
composition text
checkbox checked state

Non-urgent:

large validation summary
search preview
derived expensive recommendation panel
autosave indicator
next-step preview

Pattern

function CaseForm() {
  const [draft, setDraft] = useState(initialDraft);
  const deferredDraft = useDeferredValue(draft);

  return (
    <>
      <CaseFields value={draft} onChange={setDraft} />
      <ExpensiveRiskPreview draft={deferredDraft} />
    </>
  );
}

Do not defer the input's own value.

Rule

User input ownership stays urgent. Expensive projections from that input may lag.

20. Concurrency and Server State

Server-state libraries interact with concurrent rendering through:

cache subscription
Suspense support
transition-aware navigation
background refetch
stale data retention
optimistic updates

Useful pattern: keep old data while fetching new data

For filter changes, you may want:

input changes now
old results remain visible
new results load in background
stale indicator shows transition
new results replace when ready

This avoids blanking.

Suspense caution

A poorly placed Suspense boundary can make every small data change blank an entire page.

<Suspense fallback={<FullScreenSpinner />}>
  <SearchPage />
</Suspense>

Better:

<SearchShell>
  <SearchControls />
  <Suspense fallback={<ResultsSkeleton />}>
    <Results />
  </Suspense>
</SearchShell>

Rule

Concurrent UX is built from cache policy + boundary placement + transition semantics.

21. Priority Inversion Failure

Priority inversion happens when low-value work blocks high-value interaction.

Examples:

typing blocked by huge result render
checkbox click blocked by dashboard recomputation
modal close blocked by analytics calculation
route transition blocked by unnecessary global provider rerender

Diagnosis

Ask:

Which update is urgent?
Which update is visual but non-urgent?
Which subtree rerenders because of context/store fan-out?
Can expensive work be deferred, memoized, virtualized, or moved server-side?

Repair ladder

1. Remove unnecessary state/effects.
2. Narrow state ownership.
3. Split context/provider boundaries.
4. Add selectors for external store subscriptions.
5. Memoize expensive pure selectors.
6. Virtualize large DOM.
7. Use transition/deferred value for non-urgent rendering.
8. Move CPU-heavy work to worker/server.

Do not start at step 7 if step 1 is the real bug.


22. Tearing Mental Model

Tearing means different parts of UI observe inconsistent versions of external mutable data during a render.

React state and context avoid this by being snapshot-based inside render.

External mutable stores need a proper snapshot protocol.

Bad:

function A() {
  return <div>{externalStore.state.count}</div>;
}

function B() {
  return <div>{externalStore.state.count}</div>;
}

If externalStore.state mutates during render, A and B might observe different values.

Better:

const count = useSyncExternalStore(
  externalStore.subscribe,
  externalStore.getSnapshot,
).count;

Rule

Concurrent rendering requires reads to be snapshot-consistent.

23. Transition Failure Modes

FailureSymptomFix
Transitioning controlled input valueinput lagskeep input update urgent
Wrapping everything in transitionUI feels delayedclassify update priority
No pending indicatoruser thinks app ignored actionuse isPending or stale indicator
Expensive commit/layout effecttransition still janksreduce commit work
Stale callback inside transitionwrong update appliedupdater function/reducer event
Transition hides important freshnessuser sees stale critical dataexplicit stale/pending marker
Transition used for command correctnessduplicate/conflict bugs remainidempotency/precondition on backend

24. Suspense Failure Modes

FailureSymptomFix
Huge boundaryfull page blankssmaller nested boundaries
Boundary too tinymany skeleton flickersgroup by user-perceived loading unit
No error boundaryrejected async crashes awkwardlypair Suspense with Error Boundary
Fetch in effect expected to suspendfallback never appearsuse Suspense-enabled data source
Re-suspend hides revealed contentflicker on updatestransition-aware update / boundary redesign
Skeleton mismatches layoutlayout shiftskeleton should preserve geometry

25. A Production-Oriented Concurrency Diagram

This diagram is a practical review tool.

For any state update, ask:

Is this urgent?
Can it be transitioned?
Is it async data that belongs to cache/Suspense?
Is it an external synchronization that belongs in an effect?
Will render remain pure if React discards it?

26. Code Review Checklist

Use this checklist when reviewing concurrent-sensitive React code.

[ ] Component render is pure.
[ ] No external mutation during render.
[ ] No network/subscription/timer starts during render.
[ ] Random/time values are not used as stable identity in render.
[ ] State updates depending on previous state use updater/reducer.
[ ] Controlled input value updates are urgent.
[ ] Expensive result updates are transition/deferred where useful.
[ ] Pending/stale UI state is visible when domain requires it.
[ ] Suspense boundaries match user-perceived loading units.
[ ] Error boundaries pair with async/Suspense regions.
[ ] Layout effects are small and necessary.
[ ] External stores use `useSyncExternalStore` or a library with equivalent contract.
[ ] Snapshot values are stable when underlying store data has not changed.
[ ] Context updates do not fan out high-frequency global rerenders.
[ ] Strict Mode warnings/duplicates are treated as signals, not ignored.

27. Exercises

Exercise 1 — Split urgent and non-urgent state

Take this component:

function SearchPage() {
  const [query, setQuery] = useState('');
  const results = expensiveSearch(query);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Results results={results} />
    </>
  );
}

Refactor it so the input stays urgent and the result calculation can lag.

Expected directions:

useDeferredValue(query)
useMemo for expensive pure calculation if inputs are stable
server-side search if dataset is too large
virtualization if result DOM is large

Exercise 2 — Remove render impurity

Find every impure operation:

function AuditPanel({ caseId }: { caseId: string }) {
  auditBus.publish('panel.rendered', { caseId });
  const now = Date.now();
  const id = Math.random();

  return <section id={`audit-${id}`}>Rendered at {now}</section>;
}

Refactor based on intent:

analytics belongs in effect or visibility observer
stable id belongs in useId or data identity
current time belongs in state/effect if it changes over time

Exercise 3 — Suspense boundary placement

Given this layout:

<Suspense fallback={<FullPageSpinner />}>
  <CasePage />
</Suspense>

Redesign boundaries for:

case header
case timeline
related cases
audit trail
comment editor

Expected direction: preserve page shell and show skeletons only for incomplete regions.


28. Final Mental Model

Concurrent React rewards code that is already architecturally clean.

It punishes code that smuggles side effects into render, relies on accidental synchronous timing, or treats all UI updates as equal priority.

The mature model is:

Render is pure calculation.
Commit is the only visible host mutation.
Effects synchronize committed UI with external systems.
Urgent updates protect direct manipulation.
Transitions make non-urgent visual work interruptible.
Deferred values let expensive children lag behind urgent input.
Suspense boundaries define loading architecture.
External stores must provide stable snapshots.
Server-state caches must coordinate freshness and identity.

Do not use concurrent features as decoration.

Use them only after you can clearly say:

This update is urgent.
That update is non-urgent.
This subtree may suspend.
This fallback preserves user orientation.
This external store read is snapshot-safe.
This render can be thrown away without consequence.

That is the concurrency-level React mental model.


References

Lesson Recap

You just completed lesson 78 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.