What concurrent rendering actually means
In React 17 and below every render was synchronous and blocking. Once React started computing a new tree it ran to completion — nothing else on the main thread could run until it finished. A slow render meant a frozen UI.
React 18's concurrent mode changes this by making rendering interruptible. React works in small time slices and regularly yields control back to the browser. If a higher-priority update arrives mid-render — say a keypress while a large list is filtering — React can pause the low-priority work, handle the urgent event, and then restart the deferred render from scratch.
import { createRoot } from 'react-dom/client'
// One-line opt-in to concurrent mode
const root = createRoot(document.getElementById('root'))
root.render(<App />)
// Legacy ReactDOM.render stays in blocking mode — no concurrent features
The important consequence for developers: render functions must be pure. React may call your component function multiple times for the same update during concurrent work. Side effects still belong in useEffect, which runs exactly once per commit regardless of how many times React speculatively rendered.
Suspense boundaries
A <Suspense> boundary is a declarative loading fence. When any component inside it is not ready to render — because a code chunk is still downloading, or a data fetch is in-flight — React replaces the boundary's children with its fallback until every suspended component is ready.
import { Suspense, lazy } from 'react'
const HeavyChart = lazy(() => import('./HeavyChart'))
function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
)
}
Nesting boundaries gives independent loading states to different regions. The <Sidebar> and <Feed> below each show their own skeleton without blocking each other, and without blocking the header, which renders immediately.
<>
<Header /> {/* renders right away */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* resolves on its own timeline */}
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<Feed /> {/* resolves independently */}
</Suspense>
</>
The golden rule: one Suspense boundary per independently loading region, not one per component.
Suspense for data fetching and React.use()
React 18 extended Suspense beyond lazy-loaded code to cover data — any Promise a component declares it depends on. The mechanism is the same: the component throws a Promise, React catches it, shows the fallback, and re-renders when the Promise resolves.
React.use() (stable in React 19, experimental in React 18.3) is the standard way to trigger this:
import { use, Suspense } from 'react'
// Kick off the fetch BEFORE rendering — "render-as-you-fetch"
const userPromise = fetch('/api/me').then(r => r.json())
function UserCard() {
const user = use(userPromise) // suspends if pending; throws if rejected
return <h2>Hello, {user.name}</h2>
}
function App() {
return (
<Suspense fallback={<p>Loading profile…</p>}>
<UserCard />
</Suspense>
)
}
The critical pattern above is render-as-you-fetch: the Promise is created outside the component, before React renders it. This eliminates the mount-then-fetch waterfall where a component has to render once just to know what to fetch.
useTransition for non-urgent updates
useTransition lets you classify a state update as low priority so React keeps the current UI interactive while computing the new one.
import { useState, useTransition } from 'react'
function SearchPage({ items }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState(items)
const [isPending, startTransition] = useTransition()
function handleChange(e) {
setQuery(e.target.value) // urgent — input stays snappy
startTransition(() => {
setResults(items.filter(i =>
i.name.toLowerCase().includes(e.target.value)
)) // non-urgent — can be deferred
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <span>Updating…</span>}
<ul>{results.map(i => <li key={i.id}>{i.name}</li>)}</ul>
</>
)
}
isPending is true while the deferred render is in progress, making it easy to show a subtle indicator. Because startTransition wraps the setter, you control the deferral at the source of the update.
useDeferredValue for derived state
useDeferredValue is the complement to useTransition. Instead of wrapping the setter, it wraps a received value — useful when the state is owned by a parent you cannot modify.
import { useDeferredValue } from 'react'
function ExpensiveList({ query }) {
// query comes from a parent — we can't add startTransition there
const deferredQuery = useDeferredValue(query)
const filtered = heavyFilter(deferredQuery) // runs with the stale value first
return <ul>{filtered.map(i => <li key={i.id}>{i.name}</li>)}</ul>
}
React renders the parent immediately with the latest query (for the input), then re-renders ExpensiveList in the background with deferredQuery catching up. The user sees a snappy input and a briefly stale list — which is almost always preferable to a janky UI.
When to choose which:
| Scenario | API |
|---|---|
| You own the state setter | useTransition |
| Value arrives as a prop/context | useDeferredValue |
| Need to show a pending indicator | useTransition (has isPending) |
| Deferring an expensive child render | Either; useDeferredValue is simpler |
Streaming SSR with Suspense in Next.js
Next.js App Router runs React Server Components on the server and uses HTTP streaming to send HTML to the browser in chunks. Each <Suspense> boundary is a streaming flush point: the shell HTML (layout, headings, skeleton fallbacks) arrives instantly, and as each async Server Component resolves its await, Next.js streams the finished HTML chunk and a hydration script that swaps the skeleton out.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats } from './UserStats' // awaits a DB query
import { RecentOrders } from './RecentOrders' // awaits a different DB query
export default function Dashboard() {
return (
<main>
<h1>Dashboard</h1>
{/* Shell arrives immediately; each chunk streams as its query resolves */}
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</main>
)
}
Without Suspense boundaries the server would wait for all data before sending any HTML, making Time to First Byte as slow as your slowest query. With boundaries the page is progressively useful from the first byte.
Choosing the right API
React.lazy+<Suspense>— always the right call for route-level code splitting.React.use(promise)+<Suspense>— data fetching in React 19 / Next.js App Router.useTransition— any expensive client-side state update you own (navigation, filtering).useDeferredValue— expensive render triggered by a prop or context you do not own.createRoot— the single switch that unlocks all of the above; migrate fromReactDOM.renderas a first step.
Concurrent features are additive. You can adopt them incrementally: upgrade to createRoot, wrap a slow route in <Suspense>, add useTransition to the one search box that feels sluggish. Each step is independent and reversible.