21 — Concurrent Features
Automatic Batching — React 18's Default Behavior
automatic_batching.js
import { useState } from 'react'
// Before React 18: only setState calls inside React event handlers were batched.
// setState inside setTimeout, promises, or native listeners each triggered
// a SEPARATE re-render. React 18 batches ALL setState calls regardless of
// where they occur — a genuine behavioral change, not just an optimization.
function ProductForm() {
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState(null)
async function handleSave() {
setIsSaving(true)
try {
await saveProduct()
} catch (err) {
// React 18: both setState calls below are batched into ONE re-render.
// React 17: each triggered its own separate re-render (two renders).
setError(err)
setIsSaving(false)
}
}
return <button onClick={handleSave} disabled={isSaving}>Save</button>
}
flushSync — The Escape Hatch
flush_sync.js
import { flushSync } from 'react-dom'
// flushSync forces a synchronous, unbatched re-render — opting OUT of
// automatic batching. Use it ONLY when you need the DOM to update between
// two state changes (e.g., scrolling before a heavy render).
// DISCOURAGED by default — it defeats the batching improvements.
function handleScrollAndRender(newScrollPos) {
flushSync(() => {
setScrollPosition(newScrollPos) // DOM updates immediately
})
// Now the DOM reflects the new scroll position before this runs:
setHeavyContent(computeExpensiveContent()) // separate render
}
startTransition — Marking Updates as Non-Urgent
start_transition.js
import { useState, startTransition } from 'react'
// Not every state update is equally urgent. Typing into an input needs to
// feel INSTANT — the character must appear the moment a key is pressed.
// Re-filtering a 10,000-row list based on that keystroke is less urgent.
// startTransition tells React to treat a state update as LOW PRIORITY,
// letting urgent updates (the input reflecting what was typed) interrupt
// and render first.
function ProductSearch({ allProducts }) {
const [query, setQuery] = useState('')
const [filtered, setFiltered] = useState(allProducts)
function handleChange(e) {
const value = e.target.value
setQuery(value) // URGENT: the input must visibly update immediately
startTransition(() => {
// NON-URGENT: React can delay/interrupt this expensive filter if
// something more urgent (the next keystroke) comes in first.
setFiltered(allProducts.filter(p =>
p.name.toLowerCase().includes(value.toLowerCase())
))
})
}
return (
<div>
<input value={query} onChange={handleChange} />
<ProductGrid products={filtered} />
</div>
)
}
// Without startTransition, typing quickly on a large catalog feels laggy —
// every keystroke triggers a synchronous, expensive re-filter before the
// browser is free to paint the next character. With startTransition, the
// input stays responsive because React prioritizes query's update first.
startTransition Must Be Synchronous
transition_sync.js
// CORRECT: the setState call is synchronous within the transition callback.
startTransition(() => {
setFiltered(expensiveFilter(allProducts, value)) // sync — marked low-priority
})
// WRONG: async callback — only the SYNCHRONOUS portion is tracked as part
// of the transition. Anything after `await` runs OUTSIDE the transition's
// low-priority marking, defeating the purpose.
startTransition(async () => {
setQuery(value) // this IS in the transition (sync, before await)
const results = await runExpensiveFilter(allProducts, value)
setFiltered(results) // this is NOT in the transition (after await)
})
// FIX: keep urgent updates OUTSIDE the transition, and keep the
// transition callback synchronous:
setQuery(value) // urgent — outside transition
startTransition(() => {
setFiltered(syncFilter(allProducts, value)) // non-urgent, synchronous
})
// If you need async work, perform it outside, then mark only the
// state update as a transition:
setQuery(value)
runExpensiveFilter(allProducts, value).then(results => {
startTransition(() => setFiltered(results))
})
useTransition — Tracking Pending State
use_transition.js
import { useState, useTransition } from 'react'
// useTransition pairs startTransition's functionality with an isPending
// boolean, letting the UI show that a low-priority update is in flight.
function ProductSearch({ allProducts }) {
const [query, setQuery] = useState('')
const [filtered, setFiltered] = useState(allProducts)
const [isPending, startTransition] = useTransition()
function handleChange(e) {
const value = e.target.value
setQuery(value) // urgent — input updates immediately
startTransition(() => {
setFiltered(allProducts.filter(p =>
p.name.toLowerCase().includes(value.toLowerCase())
))
})
}
return (
<div>
<input value={query} onChange={handleChange} />
{/* isPending is true between calling startTransition and the
transition's update actually committing — dim the grid slightly
to give visual feedback that a filter is in progress */}
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<ProductGrid products={filtered} />
</div>
</div>
)
}
// isPending is a better UX signal than a manually-tracked isFiltering boolean
// — it's wired directly into React's own scheduling, accurately reflecting
// exactly when the deferred work is actually in flight versus already committed.
useTransition for Tab Switching
transition_tabs.js
import { useState, useTransition, lazy, Suspense } from 'react'
// Tab switching to a heavy component can stutter if the new tab's render
// is expensive. useTransition keeps the current tab interactive while the
// new one renders in the background.
const HeavyTab = lazy(() => import('./HeavyTab'))
const SettingsTab = lazy(() => import('./SettingsTab'))
function TabContainer() {
const [tab, setTab] = useState('heavy')
const [isPending, startTransition] = useTransition()
function selectTab(nextTab) {
startTransition(() => setTab(nextTab)) // non-urgent tab switch
}
return (
<div>
<nav>
<button onClick={() => selectTab('heavy')} disabled={isPending}>Heavy</button>
<button onClick={() => selectTab('settings')} disabled={isPending}>Settings</button>
</nav>
{/* Show the old tab while the new one renders — no blank flash */}
<Suspense fallback={<Spinner />}>
{tab === 'heavy' ? <HeavyTab /> : <SettingsTab />}
</Suspense>
</div>
)
}
useDeferredValue — Deferring a Value
use_deferred_value.js
import { useState, useDeferredValue, useMemo } from 'react'
// useDeferredValue solves the same problem as startTransition from the
// OPPOSITE direction. Instead of marking the STATE UPDATE as low-priority
// at the point it's set, it lets a component request a "lagging" version
// of an already-existing value — useful when the value comes from a prop
// or another hook you don't control the setState call for.
function ProductSearch({ allProducts }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
// ProductGrid re-renders using deferredQuery, which "lags behind" query
// during rapid typing — React prioritizes keeping the input itself
// (bound to query, not deferredQuery) responsive.
const filtered = useMemo(
() => allProducts.filter(p =>
p.name.toLowerCase().includes(deferredQuery.toLowerCase())
),
[allProducts, deferredQuery]
)
const isStale = query !== deferredQuery // true while a deferred update is in flight
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<ProductGrid products={filtered} />
</div>
</div>
)
}
// PRACTICAL DIFFERENCE from startTransition:
// - startTransition: you OWN the setState call -> wrap it in startTransition
// - useDeferredValue: you RECEIVE a value (prop, library hook) -> defer it
// You can't wrap someone else's state setter in startTransition, but you
// CAN defer any value you have access to.
useDeferredValue for Filtered Lists
deferred_list.js
import { useDeferredValue, useMemo, memo } from 'react'
// The memoized child receives the deferred value — it only re-renders
// when deferredQuery changes (lagging behind the urgent query updates).
const SearchResults = memo(function SearchResults({ query, items }) {
// This component renders only when `query` (the deferred value) changes.
// During rapid typing, it skips intermediate renders — React processes
// the urgent input updates first, then catches up on the deferred value.
const results = useMemo(() => {
const lower = query.toLowerCase()
return items.filter(i => i.name.toLowerCase().includes(lower))
}, [query, items])
return (
<ul>
{results.map(r => <li key={r.id}>{r.name}</li>)}
</ul>
)
})
function SearchPage({ items }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<SearchResults query={deferredQuery} items={items} />
</div>
)
}
Suspense for Data Fetching
suspense_data.js
import { Suspense } from 'react'
// Ch 20 covered <Suspense> paired with React.lazy for code loading.
// The same primitive extends to DATA — a component can "suspend" (signal
// it isn't ready to render yet) while data loads, letting a <Suspense>
// boundary show fallback UI — unifying loading states for code and data.
// Conceptual illustration — the exact suspense-enabled fetching API is
// provided by a data-fetching library (React Query's useSuspenseQuery)
// or framework (Next.js App Router), NOT hand-rolled fetch calls.
// With React Query's useSuspenseQuery:
import { useSuspenseQuery } from '@tanstack/react-query'
function ProductDetail({ productId }) {
const { data: product } = useSuspenseQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
})
// No loading branch here at all — if data isn't ready, this component
// "suspends," and the nearest ancestor <Suspense> shows its fallback
// instead of this component's output.
return <h1>{product.name}</h1>
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<ProductDetail productId="42" />
</Suspense>
)
}
Suspense with Nested Data Loading
suspense_nested.js
import { Suspense } from 'react'
// Nested Suspense boundaries let different parts of the page load
// independently — the outer boundary shows a page-level fallback,
// inner boundaries show section-level fallbacks as each resolves.
function ProductPage({ productId }) {
return (
<Suspense fallback={<PageSkeleton />}>
<ProductInfo productId={productId} />
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={productId} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts productId={productId} />
</Suspense>
</Suspense>
)
}
// ProductInfo, ProductReviews, and RelatedProducts each suspend
// independently — the page shell + product info appear first, then
// reviews and related products stream in as their data resolves.
Why Hand-Rolled Suspense Doesn't Work
suspense_pitfall.js
// BROKEN: throwing a raw, uncached promise from a component's render body
// produces an INFINITE RE-RENDER LOOP, not a working suspense boundary.
// A new promise is thrown on every render attempt with nothing to recognize
// it as "the same" pending request.
function BrokenProductDetail({ productId }) {
// Every render creates a NEW promise and throws it — React catches it,
// shows fallback, then tries to re-render, creates ANOTHER new promise...
throw fetch(`/api/products/${productId}`).then(r => r.json())
}
// A correct Suspense integration requires:
// 1. Caching: the same promise must be returned for the same key until it
// resolves, so re-render attempts get the SAME promise, not a new one.
// 2. Throwing the promise from render (not useEffect) — React catches it.
// 3. After resolution, the cache returns the resolved value, not a promise.
// This is why production Suspense-for-data uses libraries (React Query,
// Relay, Next.js) that handle this contract correctly — don't reinvent it.
Concurrent Rendering Requires Purity
purity_requirement.js
// Concurrent rendering means React may call a component's render function,
// pause partway through the tree, discard that render entirely without
// committing it, and start over. This is impossible pre-18 (a render, once
// started, always ran to completion and committed).
let renderCount = 0
function ProductCard({ product }) {
renderCount++ // IMPURE: a side effect during render
console.log(`Rendered ${renderCount} times`)
// Under concurrent rendering, React may call this function, discard the
// result without committing/painting it, and call it again — renderCount
// now over-counts, reporting numbers that don't correspond to actual
// commits the user ever saw.
return <div>{product.name}</div>
}
// CORRECT: side effects belong in useEffect (which only runs after commit),
// not in the render body. Render must be PURE — same input, same output,
// no external mutations.
function PureProductCard({ product }) {
// Computing derived values during render is fine (it's pure):
const displayName = product.name.toUpperCase()
return <div>{displayName}</div>
}
Why Purity Matters Under Concurrent Rendering
purity_concurrent.js
// Under concurrent rendering, React can:
// 1. Start rendering a tree
// 2. Be interrupted by a higher-priority update (e.g., user input)
// 3. DISCARD the in-progress render entirely
// 4. Start a fresh render with the new state
// This means a component render function may be called MORE times than
// the component actually commits to the DOM. Any code that relies on
// "render runs exactly once per commit" breaks:
// BROKEN: mutating a ref during render to track "first render"
function Component({ data }) {
if (!ref.current.initialized) {
ref.current.initialized = true
ref.current.processedData = expensiveProcess(data) // side effect in render
}
return <div>{ref.current.processedData}</div>
}
// React may call this twice during a concurrent render, discard the first
// call, and the ref now holds state from a discarded render attempt.
// CORRECT: use useMemo for expensive derived state, useEffect for side effects
function Component({ data }) {
const processedData = useMemo(() => expensiveProcess(data), [data])
return <div>{processedData}</div>
}
When Concurrent Helps vs Doesn't
when_concurrent_helps.js
// CONCURRENT HELPS when:
// 1. An expensive render would block urgent updates (typing, clicks)
// -> useTransition/useDeferredValue lets urgent updates interrupt
// 2. Multiple state updates happen in the same tick
// -> automatic batching reduces re-renders (no code change needed)
// 3. Parts of a page load data independently
// -> Suspense boundaries let fast sections appear before slow ones
// 4. Tab/Route switching to heavy components
// -> useTransition keeps the current tab interactive while the new one renders
// CONCURRENT DOESN'T HELP when:
// 1. The render itself is cheap (< 1ms) — there's nothing to interrupt
// 2. There's only one state update — nothing to batch or prioritize
// 3. The bottleneck is network, not rendering — Suspense shows a spinner
// either way; concurrent doesn't make the data arrive faster
// 4. The component is impure — concurrent makes it WORSE, not better
// ANTI-PATTERN: wrapping EVERY state update in startTransition "just in case."
// startTransition adds overhead (tracking the transition scope). For cheap
// updates (toggling a boolean), it's unnecessary — the render is fast enough
// that it never needs to be interrupted. Reserve for MEASURABLY expensive renders.
// ANTI-PATTERN: using useDeferredValue on a value that never changes rapidly.
// If the value updates at most once per user action (not per keystroke),
// there's nothing to defer — useDeferredValue just adds overhead.
Complex Implementation — Responsive Search with Transitions
complex_search.js
import { useState, useTransition, useDeferredValue, memo, useMemo } from 'react'
// A production-grade search that stays responsive on 50k+ items:
// - useTransition for the filter update (non-urgent)
// - isPending for visual feedback (dimming)
// - memo on the results list to skip re-renders when query is stale
const SearchResults = memo(function SearchResults({ items, filterText }) {
const filtered = useMemo(() => {
if (!filterText) return items
const lower = filterText.toLowerCase()
return items.filter(i =>
i.name.toLowerCase().includes(lower) ||
i.sku.toLowerCase().includes(lower)
)
}, [items, filterText])
return (
<div>
<p>{filtered.length} results</p>
<ul>
{filtered.slice(0, 100).map(item => (
<li key={item.id}>{item.name} — {item.sku}</li>
))}
</ul>
</div>
)
})
function ProductSearch({ allProducts }) {
const [query, setQuery] = useState('')
const [filterText, setFilterText] = useState('')
const [isPending, startTransition] = useTransition()
function handleChange(e) {
const value = e.target.value
setQuery(value) // URGENT: input updates immediately
startTransition(() => {
setFilterText(value) // NON-URGENT: results update when React is free
})
}
return (
<div>
<input
value={query}
onChange={handleChange}
placeholder="Search 50k products…"
/>
<div style={{ opacity: isPending ? 0.7 : 1, transition: 'opacity 0.15s' }}>
<SearchResults items={allProducts} filterText={filterText} />
</div>
</div>
)
}
// ALTERNATIVE using useDeferredValue (same result, different approach):
function ProductSearchDeferred({ allProducts }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const isStale = query !== deferredQuery
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<SearchResults items={allProducts} filterText={deferredQuery} />
</div>
</div>
)
}
💡 Tips & Tricks
tips.js
// [Idiom] Reach for startTransition specifically for updates that are
// expensive to render but not urgent to feel instantaneous (re-filtering
// a large list, re-computing a chart) — leave genuinely urgent updates
// (text typed into an input) as regular, unwrapped setState calls.
// [Idiom] Choose useDeferredValue over startTransition when you receive a
// value you don't control the setter for (a prop, a third-party hook's
// return value); choose startTransition when you do own the setState call.
// [Debug] If automatic batching in React 18 changes behavior that pre-18
// code relied on (an intermediate render between two setState calls in an
// async callback), the discouraged escape hatch is flushSync from react-dom.
// [Performance] isPending from useTransition is a better UX signal than a
// manually-tracked isFiltering boolean — it's wired directly into React's
// own scheduling, accurately reflecting when the deferred work is in flight.
// [Idiom] Treat "components must be pure" as a HARD requirement, not a style
// guideline, once concurrent features are in play — a component with
// render-time side effects can produce subtly wrong behavior (double-counted
// effects, stale closures over mutated external variables) specifically
// because React may render it speculatively and discard the result.
// [Performance] Don't wrap every setState in startTransition — it adds
// overhead for cheap renders that never need interruption. Reserve for
// MEASURABLY expensive renders confirmed via profiling.
⚠️ Edge Cases & Gotchas
edge_cases.js
// [Gotcha] Automatic batching is a genuine behavioral change from React 17
// and earlier, not just an optimization. Code that relied on synchronous,
// unbatched setState inside a setTimeout/promise/native-event callback
// behaves differently. flushSync is the explicit opt-out for rare cases.
// [Gotcha] startTransition's callback must call setState SYNCHRONOUSLY.
// Wrapping an async function or using `await` inside the callback doesn't
// work — only state updates that happen synchronously within the transition
// are marked low-priority. Anything after `await` runs outside the marking.
// [Gotcha] A transition update can be INTERRUPTED and its render thrown away
// entirely if a more urgent update comes in. This is intentional (keeps input
// responsive during rapid typing), but it means a transition's side effects
// (if any existed, which they shouldn't per the purity requirement) could
// appear to run inconsistently.
// [Gotcha] useDeferredValue does NOTHING useful on a value's very first render.
// On initial mount, deferredValue equals value immediately; the "lagging"
// behavior only manifests on subsequent updates once there's a previous value
// to lag behind. Testing it against a component's first render looks broken.
// [Gotcha] Suspense-for-data has real integration requirements (throwing a
// promise from render, caching resolved values across attempts) that hand-
// written fetch calls do not satisfy without a library's support. Throwing a
// raw, uncached promise from a component body produces an infinite re-render
// loop, not a working suspense boundary.
// [Gotcha] useTransition's isPending stays true from the moment startTransition
// is called until the transition's update COMMITS (not just until it starts
// rendering). For very fast renders, this window is imperceptible — but if
// you use isPending to show a loading spinner, it may flash too briefly.
// [Gotcha] Concurrent rendering can cause useEffect to run in a different
// order than expected if React renders speculatively and discards. Effects
// only run after a commit, but the commit order can differ from render order
// when concurrent features interrupt and restart rendering.
🧠 Spot the Bug
A developer wraps an expensive search-filtering update in startTransition to keep the input responsive, but the input still visibly stutters while typing quickly.
spot_the_bug.js
function ProductSearch({ allProducts }) {
const [query, setQuery] = useState('')
const [filtered, setFiltered] = useState(allProducts)
async function handleChange(e) {
const value = e.target.value
startTransition(async () => {
setQuery(value)
const results = await runExpensiveFilter(allProducts, value)
setFiltered(results)
})
}
return (
<div>
<input value={query} onChange={handleChange} />
<ProductGrid products={filtered} />
</div>
)
}
Answer
Two problems compound here:
setQuery(value)— the update that needs to feel instantaneous — is placed inside thestartTransitioncallback, marking it low-priority along with the expensive filter. The input itself now competes for priority instead of being exempted from it — exactly backwards from the intent.- The callback passed to
startTransitionisasync, and only the synchronous portion of a transition callback is actually tracked as part of the transition.await runExpensiveFilter(...)and thesetFilteredcall after it run outside the transition's low-priority marking entirely, once the microtask queue resumes them — defeating the purpose a second time over.
Fix: keep urgent updates outside the transition, keep the transition callback synchronous, and perform async work outside then mark only the state update:
function handleChange(e) {
const value = e.target.value
setQuery(value) // urgent — OUTSIDE the transition
startTransition(() => {
setFiltered(allProducts.filter(p => p.name.toLowerCase().includes(value.toLowerCase())))
// synchronous filter — stays inside the transition
})
}
The lesson: only wrap the state updates that are genuinely non-urgent inside startTransition, keep urgent updates outside, and keep the transition callback synchronous — await inside it silently escapes the low-priority scheduling for anything after the awaited expression.
Key Takeaways
key_takeaways.js
// 1. React 18's automatic batching groups ALL setState calls into a single
// re-render regardless of where they occur (event handler, promise,
// setTimeout) — a genuine behavior change, not merely an optimization.
// 2. startTransition marks a state update as LOW-PRIORITY, letting React
// interrupt it in favor of urgent updates. The callback MUST be
// synchronous — only synchronously-scheduled updates are deprioritized.
// 3. useTransition pairs startTransition with isPending for React-native
// loading state — more reliable than manually tracked boolean flags.
// 4. useDeferredValue achieves a similar "lagging" effect for a value you
// don't own the setter for (a prop, a library hook's return value).
// 5. Concurrent rendering requires component render functions to be PURE —
// React may call, pause, and discard a render entirely without committing
// it. Side effects during render break under concurrent features.
// 6. Suspense for data extends the fallback-UI mechanism from code-splitting
// to async data, but requires library/framework support (React Query's
// useSuspenseQuery, Next.js) — hand-thrown, uncached promises loop.
// 7. Don't wrap every update in startTransition — it adds overhead for
// cheap renders. Reserve for MEASURABLY expensive renders confirmed
// via profiling. Concurrent helps when expensive renders block urgent
// updates; it doesn't help when the bottleneck is network or the render
// is already fast.