What changed in React 18 and why it matters — startTransition, useDeferredValue, automatic batching, flushSync, and the atomic commit guarantee explained precisely.
React 18 introduced one fundamental idea: not all state updates are equally urgent. Before React 18, every setState call was treated with the same urgency. React would process all updates synchronously and as fast as possible. This worked fine until you had expensive renders — like filtering a list of 10,000 items or rendering a complex chart. Every keystroke triggered an expensive synchronous render, making the input feel laggy. The core of React 18's concurrent features is the distinction between urgent updates and transition updates. Urgent updates are things the user did directly and expects an immediate response to: typing, clicking, pressing a key. Transition updates are updates that show the result of an urgent update — filtering a list based on what was typed, navigating to a new page, loading new data. The result can take a moment to appear without breaking the feel of the app. startTransition lets you explicitly mark an update as a transition — low priority, deferrable, interruptible. React will keep the previous UI visible until the transition update is ready. If more urgent updates arrive during the transition render, React abandons the transition work and starts it over. The user never sees an intermediate state. useDeferredValue is the same idea for values rather than setter calls — when you can't wrap the setState in a startTransition because it comes from a third party. Automatic batching means React 18 batches all setState calls together into one render, even in setTimeout, fetch callbacks, and native event handlers. Before React 18, batching only happened inside React event handlers. These features only work because of Fiber's interruptible rendering. React 18 is just the API surface that exposes what Fiber's architecture made possible since React 16.
// Without startTransition — input lags because filter render blocks it
function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState(allItems)
function handleChange(e) {
setQuery(e.target.value) // urgent — input must update immediately
setResults(filter(allItems, e.target.value)) // expensive — but treated equally urgent
// React renders BOTH synchronously — input update waits for filter to complete
// Result: input lags by however long filter takes (e.g. 200ms)
}
return (
<>
<input value={query} onChange={handleChange} />
<ResultList items={results} />
</>
)
}
// With startTransition — input is instant, results trail slightly
function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState(allItems)
function handleChange(e) {
setQuery(e.target.value) // urgent — SyncLane — renders immediately
startTransition(() => {
// This update is TransitionLane — low priority
// React will render this only when the main thread is free
// If another keystroke arrives, this render is abandoned and restarted
setResults(filter(allItems, e.target.value))
})
}
return (
<>
<input value={query} onChange={handleChange} />
<ResultList items={results} /> {/* may show stale results briefly — that's ok */}
</>
)
}
// useTransition — same idea, plus isPending flag
function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState(allItems)
const [isPending, startTransition] = useTransition()
function handleChange(e) {
setQuery(e.target.value)
startTransition(() => {
setResults(filter(allItems, e.target.value))
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />} {/* show loading indicator while transition runs */}
<ResultList items={results} />
</>
)
}
// The guarantee React makes:
// Any render that gets interrupted NEVER commits to the DOM.
// The user ALWAYS sees either the old complete UI or the new complete UI.
// They never see a partial render.
// Timeline with rapid typing "abc":
// t=0: type 'a' → setQuery('a') + startTransition(→ setResults(filter('a')))
// t=0: query renders immediately (input shows 'a')
// t=0: React begins TransitionLane render for results
// t=5ms: type 'b' → setQuery('ab') + startTransition(→ setResults(filter('ab')))
// t=5ms: React ABORTS the 'a' transition render — it will never commit
// t=5ms: query renders immediately (input shows 'ab')
// t=5ms: React begins TransitionLane render for filter('ab')
// t=8ms: type 'c' → same thing — 'ab' transition aborts
// t=8ms: React begins TransitionLane render for filter('abc')
// t=20ms: no more keystrokes — transition completes and commits
// Result: Results show filter('abc'). User never saw filter('a') or filter('ab') results.
// This "latest wins" behavior is the whole point.
// You never need to debounce a startTransition — React handles it.
// useDeferredValue is for when you can't wrap the setState in startTransition —
// e.g. the state update happens in a library you don't control, or comes from a URL param.
function SearchResults({ query }) {
// deferredQuery "lags behind" query intentionally
// During a fast-changing query, deferredQuery stays on the last committed value
// while React works on the transition render for the new value
const deferredQuery = useDeferredValue(query)
// This expensive component renders with the deferred (possibly stale) value
// while the urgent component (the input) renders with the current value
return <ExpensiveList filter={deferredQuery} />
}
function App() {
const [query, setQuery] = useState('')
return (
<>
{/* This updates immediately — urgent */}
<input value={query} onChange={e => setQuery(e.target.value)} />
{/* This may show stale query while new render is in progress */}
<SearchResults query={query} />
</>
)
}
// CRITICAL: useDeferredValue is NOT debounce
// Debounce: delay the update by a fixed time (e.g. 300ms). Timer-based.
// useDeferredValue: show the previous value while React renders the new one.
// No timer — it defers until React is ready.
// If the machine is fast, the deferred value updates instantly.
// If the machine is slow (or the render is expensive), it lags by the render duration.
// If the render is abandoned (new value arrives), the deferred value doesn't update at all.
// To show "stale" visual signal:
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query)
const isStale = query !== deferredQuery // true while transition is running
return (
<div style={{ opacity: isStale ? 0.5 : 1, transition: 'opacity 0.2s' }}>
<ExpensiveList filter={deferredQuery} />
</div>
)
}
// BEFORE React 18 — batching only inside React event handlers
function handleClick() {
setA(1) // batched ↓
setB(2) // batched ↓ → 1 render
}
function handleClick() {
setTimeout(() => {
setA(1) // NOT batched — 1 render
setB(2) // NOT batched — 1 render
// Total: 2 renders!
}, 0)
}
async function handleClick() {
await fetch('/api/data')
setA(1) // NOT batched — 1 render
setB(2) // NOT batched — 1 render
// Total: 2 renders!
}
// AFTER React 18 — batching everywhere, always
// Requires: ReactDOM.createRoot() (not ReactDOM.render())
function handleClick() {
setTimeout(() => {
setA(1) // batched ↓
setB(2) // batched ↓ → 1 render ✓
}, 0)
}
async function handleClick() {
await fetch('/api/data')
setA(1) // batched ↓
setB(2) // batched ↓ → 1 render ✓
}
// Native event handlers also get batching:
document.addEventListener('click', () => {
setA(1) // batched ↓
setB(2) // batched ↓ → 1 render ✓ (was 2 renders in React 17)
})
import { flushSync } from 'react-dom'
// flushSync forces React to flush state updates synchronously and immediately.
// Use it when you need the DOM to update before the next line of code runs.
// Use case 1: Reading DOM measurements after a state update
function handleAdd() {
flushSync(() => {
setItems(prev => [...prev, newItem]) // DOM updates before flushSync returns
})
// DOM is now updated — safe to read measurements
listRef.current.scrollTop = listRef.current.scrollHeight // scroll to new item
}
// Without flushSync: setItems batches → DOM hasn't updated yet → scroll target is wrong.
// The ref still points to the old scroll height.
// Use case 2: Third-party animations that need DOM to be current
function handleTransition() {
flushSync(() => {
setVisible(true) // React commits synchronously
})
// DOM now has the element — third-party animation library can find it
animationLibrary.animate(elementRef.current, { from: 0, to: 1 })
}
// When NOT to use flushSync:
// Almost never. It bypasses batching and Concurrent Mode optimizations.
// It blocks the main thread until the update commits.
// If you find yourself using it often, you probably have an architectural problem.
// Prefer useLayoutEffect for DOM reads after renders — it's the idiomatic solution.
// React's absolute guarantee:
// The DOM is ALWAYS updated atomically — all changes from one render commit together.
// The user never sees a partial UI from a single state update.
// This holds even with:
// - startTransition (transition renders may be abandoned, but if they commit, they commit fully)
// - Automatic batching (all batched updates commit in one pass)
// - Error boundaries (if rendering throws, React rolls back to the nearest error boundary)
// - Suspense (React holds a loading state until ALL suspended children resolve)
// Suspense + atomic commit example:
function ProfilePage() {
return (
<Suspense fallback={<Spinner />}>
<Avatar /> {/* suspends on user data */}
<Bio /> {/* suspends on user data */}
<PostList /> {/* suspends on posts data */}
</Suspense>
)
}
// React won't commit the new UI until ALL three components are ready.
// The user sees: Spinner → (Avatar + Bio + PostList all at once)
// They never see: Avatar → Bio → PostList appearing one by one.
// This is atomic commit at work.
// The one exception: Suspense with startTransition
// If the transition includes a Suspense boundary, React shows the fallback
// only if the render takes too long (configurable timeout).
// Otherwise it shows the previous UI until the new one is ready.
// This is called "concurrent Suspense" and is how React avoids spinner flashing.Many developers think startTransition makes rendering faster. It does not. The render takes exactly the same amount of time. What changes is when it runs and what happens if it's interrupted. startTransition makes your app feel faster to the user by ensuring urgent updates (input, click feedback) always render first, while the expensive update runs without blocking them. The expensive render still does all the same work.
Many developers think useDeferredValue is just debouncing built into React. It is fundamentally different. Debounce introduces a fixed delay — the update waits 300ms before firing. useDeferredValue introduces no fixed delay. If the machine is fast and the render is cheap, the deferred value updates instantaneously alongside the regular value. The deferral only manifests when React needs time to process the new render, and even then it's not time-based — it's work-based.
Many developers think automatic batching is only a performance optimization. It also changes observable behavior. Before React 18, two setState calls in a setTimeout would cause two separate renders, which meant a child component reading both state values could see them in an inconsistent intermediate state. With automatic batching, the two updates always commit together — no intermediate state is ever visible. This is a correctness guarantee, not just a perf improvement.
Many developers think they need to opt into Concurrent Mode feature by feature. All of React 18's concurrent features are enabled simply by upgrading to createRoot. The key upgrade is ReactDOM.createRoot() instead of ReactDOM.render(). Without createRoot, React 18 runs in "legacy mode" with React 17 behavior — no automatic batching outside event handlers, no concurrent rendering, startTransition still works but as a fallback with no actual concurrency.
Many developers think that if startTransition can abort renders, it's unsafe to use for data mutations. This is correct — startTransition is only for rendering updates, not side effects. The state update inside startTransition can be abandoned and restarted, but any side effect (like a fetch call) will have already executed. Always do mutations (API calls, database writes) outside startTransition, and use the transition only for the resulting state update.
Many developers think flushSync is needed to read DOM after state updates. The idiomatic way to read DOM after React updates is useLayoutEffect — it fires after DOM mutations, before the browser paints. flushSync is a synchronous escape hatch for cases where you need DOM measurements in event handlers before any rendering can occur, which is rare. Using flushSync in useEffect or render creates tearing bugs.
Autocomplete search inputs are the canonical use case for startTransition. At Airbnb and similar companies, typing in the search box triggers both an instant input update (query display) and an expensive results render (filtering options). Wrapping the results setState in startTransition means keystrokes never feel laggy — React always renders the input character first and may skip intermediate filter results entirely if typing is faster than renders.
Navigation in single-page apps benefits directly from startTransition. When a user clicks a link, you want two things: instant visual feedback on the clicked link (highlight, loading indicator), and the new page renders in the background without blocking interaction. React Router v6.4+ uses startTransition internally for route transitions — the previous page stays interactive while the new page is being prepared, and isPending drives the loading indicator.
Data-heavy dashboards use useDeferredValue for chart re-rendering. When a user drags a date range slider, the slider position (urgent) updates on every frame, but the chart (expensive) re-renders only when React has time. Without useDeferredValue, dragging the slider triggers a synchronous re-render of every chart on every pixel of movement — often 60 renders per second at full render cost. With useDeferredValue, the chart uses the previous date range until React is ready to render the new one.
React 18's automatic batching fixed a class of "tearing" bugs in async event handlers at scale. A common pattern was: fetch data → setState(data) + setState(loading: false). In React 17, these two setStates inside an async callback rendered separately, meaning there was a frame where data was set but loading was still true — causing UI inconsistency. In React 18 with createRoot, they always batch into one render.
Infinite scroll implementations use Suspense with transitions for seamless page loading. As the user scrolls to the bottom, a startTransition triggers loading the next page. While the next page is being fetched and rendered, the user can still scroll through the existing content — the previous UI stays fully interactive. When the new page is ready, it commits atomically and appears all at once, not row by row.
No questions tagged to this topic yet.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.