Introduction
React 18 represents the most significant architectural change since React Hooks in version 16.8. The introduction of Concurrent React fundamentally changes how React renders your UI, enabling it to prepare multiple versions of the screen simultaneously, prioritize urgent updates over non-urgent ones, and stream server-rendered HTML to the client. These features are not just incremental improvements — they represent a paradigm shift in how we think about UI responsiveness.
Before React 18, all state updates were processed synchronously and with equal priority. When you typed in a search field, React would update the input immediately, but if updating the search results took time, the entire UI would freeze. Concurrent React solves this by making rendering interruptible — React can pause a low-priority update to handle a high-priority one, then resume where it left off.
In this comprehensive guide, we will explore every major feature in React 18: the new root API, Suspense for data fetching, useTransition for non-blocking updates, useDeferredValue for expensive computations, and streaming SSR with Suspense boundaries. You will understand not just how to use these features, but when and why they matter.
Understanding React 18: Core Concepts
Concurrent Rendering: The Foundation
The core innovation in React 18 is concurrent rendering. In React 17 and earlier, rendering was synchronous — once React started rendering a component tree, it could not be interrupted. If a component took 200ms to render, the entire UI was blocked for 200ms.
Concurrent rendering makes rendering interruptible. React can start rendering an update, pause if a more urgent update arrives (like a user typing), handle the urgent update, and then resume the original render. This happens automatically — you do not need to manage the interruption yourself.
The key insight is that React now maintains multiple versions of the UI state simultaneously. It can prepare a new screen in the background while keeping the current screen interactive. When the new screen is ready, React swaps it in seamlessly.
Opting In to Concurrent Features
Concurrent rendering is opt-in. Your existing React 17 code works identically in React 18. You opt in by using concurrent features:
createRoot()instead ofReactDOM.render()useTransition()for non-blocking state updatesuseDeferredValue()for deferred re-renders<Suspense>for declarative loading states- Server-side:
renderToPipeableStream()instead ofrenderToString()
The New Root API
// React 17 (legacy, still works but triggers warnings)
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
// React 18 (concurrent mode)
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);The new createRoot API enables concurrent features. If you use the legacy ReactDOM.render, React 18 behaves like React 17 — no concurrent features, no automatic batching.
Architecture and Design Patterns
Automatic Batching
React 18 extends automatic batching to all contexts, including setTimeout, Promise, native event handlers, and any other code. In React 17, batching only happened inside React event handlers.
// React 17: two re-renders (one per setState)
setTimeout(() => {
setCount(c => c + 1); // Re-render #1
setFlag(f => !f); // Re-render #2
}, 1000);
// React 18: one re-render (batched automatically)
setTimeout(() => {
setCount(c => c + 1); // Both batched
setFlag(f => !f); // into one re-render
}, 1000);
// Opt out of batching with flushSync (rare)
import { flushSync } from 'react-dom';
flushSync(() => {
setCount(c => c + 1);
});
// DOM is updated here
flushSync(() => {
setFlag(f => !f);
});
// DOM is updated again hereSuspense Boundaries
Suspense lets you declaratively specify loading states for parts of your component tree. When a child component is "waiting" for something (data, code, images), Suspense shows a fallback instead.
import { Suspense, lazy } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<div>
<h1>My App</h1>
<Suspense fallback={<div>Loading component...</div>}>
<HeavyComponent />
</Suspense>
</div>
);
}Suspense for Data Fetching
In React 18, Suspense works with data fetching through frameworks that support it (Next.js, Relay). The pattern uses a "suspend" mechanism — when a component tries to read data that is not yet available, it throws a Promise. React catches the Promise, shows the nearest Suspense fallback, and resumes rendering when the Promise resolves.
// Using Suspense with a data fetching library (e.g., SWR or React Query)
function UserProfile({ userId }) {
const user = use(fetchUser(userId)); // Throws Promise if not ready
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
function App() {
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={1} />
</Suspense>
);
}Step-by-Step Implementation
useTransition for Non-Blocking Updates
useTransition lets you mark state updates as non-urgent. React keeps the current UI interactive while preparing the new state in the background.
import { useTransition, useState } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleSearch(e) {
const value = e.target.value;
setQuery(value); // Urgent: update input immediately
startTransition(() => {
// Non-urgent: update results in background
setResults(performExpensiveSearch(value));
});
}
return (
<div>
<input value={query} onChange={handleSearch} />
{isPending ? (
<Spinner />
) : (
<SearchResults results={results} />
)}
</div>
);
}The key behavior: when the user types, the input updates immediately (urgent). The search results update is deferred (non-urgent). If the user types again before the results finish computing, React abandons the old computation and starts a new one with the latest query. This eliminates the janky experience where the UI freezes during expensive computations.
useDeferredValue for Expensive Renders
useDeferredValue is similar to useTransition but works at the value level rather than the state setter level. It is useful when you do not control the state update (e.g., it comes from props).
import { useDeferredValue, useMemo } from 'react';
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const results = useMemo(() => {
return filterAndSortResults(deferredQuery, 10000);
}, [deferredQuery]);
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
{results.map(item => (
<ResultCard key={item.id} item={item} />
))}
</div>
);
}
function SearchPage() {
const [query, setQuery] = useState('');
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<SearchResults query={query} />
</div>
);
}When the user types, query updates immediately, but deferredQuery lags behind. The expensive filterAndSortResults computation runs with the deferred value, keeping the input responsive. The visual indicator (isStale) tells the user that the results are being updated.
Streaming SSR with Suspense
React 18 introduces renderToPipeableStream for server-side rendering that streams HTML to the client progressively:
// server.js
import { renderToPipeableStream } from 'react-dom/server';
import { Writable } from 'stream';
app.get('/', (req, res) => {
const { pipe, abort } = renderToPipeableStream(
<App />,
{
bootstrapScripts: ['/client.js'],
onShellReady() {
// The shell (outside Suspense boundaries) is ready
// Stream it to the client immediately
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(error) {
// Fatal error — cannot render the shell
res.statusCode = 500;
res.send('<h1>Something went wrong</h1>');
},
onError(error) {
console.error(error);
}
}
);
// Abort after timeout
setTimeout(abort, 10000);
});// App.js — Streaming with Suspense
function App() {
return (
<html>
<body>
<h1>My App</h1>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</body>
</html>
);
}The server sends the shell HTML immediately, then streams each Suspense boundary's content as it becomes ready. The browser renders what it has and progressively fills in the rest. This dramatically improves Time to First Byte (TTFB) and Largest Contentful Paint (LCP).
Real-World Use Cases
Use Case 1: Search with Instant Feedback
A product catalog search that filters thousands of items benefits enormously from useTransition. Without it, every keystroke triggers an expensive filter computation that blocks the UI. With useTransition, the input stays responsive while results update in the background.
Use Case 2: Tab Switching with Heavy Content
An analytics dashboard with multiple tabs (Charts, Tables, Reports) uses Suspense with lazy-loaded tab content. Switching tabs shows a skeleton immediately while the heavy component loads in the background.
Use Case 3: Progressive News Feed
A news application uses streaming SSR with Suspense boundaries. The article shell (header, navigation) streams immediately. The article body streams next. Comments and related articles stream last. Users see content within milliseconds instead of waiting for everything to render.
Use Case 4: Form with Optimistic Updates
A task management app uses useTransition to submit form data. The UI shows the new task optimistically while the server processes the request. If the server rejects it, React rolls back to the previous state.
Best Practices for Production
-
Upgrade to createRoot immediately: The legacy
ReactDOM.rendertriggers console warnings in React 18 and disables automatic batching outside event handlers. Switch tocreateRootas your first step. -
Use useTransition for expensive state updates: If a state update triggers expensive re-renders (large lists, complex computations, heavy components), wrap it in
startTransition. This keeps the UI responsive. -
Wrap lazy components in Suspense: Any component loaded with
React.lazy()must be wrapped in a<Suspense>boundary with a meaningful fallback (skeleton, spinner, placeholder). -
Place Suspense boundaries strategically: Put them where loading states make sense visually. A boundary around the entire app shows a full-page spinner. Boundaries around individual sections show targeted loading states.
-
Use useDeferredValue for derived expensive computations: When the expensive work is in a child component receiving props (not in a state setter), useDeferredValue is the right tool.
-
Implement streaming SSR progressively: Start with the shell boundary, then add Suspense boundaries around the most expensive server-rendered components. Measure TTFB and LCP improvements.
-
Test with concurrent features enabled: Some bugs only manifest under concurrent rendering. Use React's Strict Mode during development to catch issues early.
-
Do not overuse transitions: Not every state update needs to be a transition. Use them specifically for updates that cause expensive re-renders or where the user would benefit from seeing the current UI while the new UI prepares.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Using legacy ReactDOM.render | No concurrent features, console warnings | Switch to createRoot |
| Wrapping every update in startTransition | Unnecessary overhead | Only use for expensive, non-urgent updates |
| Placing Suspense too high in the tree | Full-page loading states | Add boundaries at meaningful visual breakpoints |
| Not handling Suspense fallbacks properly | Poor loading UX | Use skeleton screens, not blank spinners |
| Forgetting streaming SSR error handling | Server hangs on errors | Use onShellError and onError callbacks |
| Assuming useTransition makes code async | Confusing mental model | It defers rendering, not the code itself |
| Testing only in legacy mode | Missing concurrent-specific bugs | Enable Strict Mode in development |
Performance Optimization
// Before: Expensive list blocks input
function FilterableList({ items }) {
const [filter, setFilter] = useState('');
const filtered = items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
return (
<>
<input onChange={e => setFilter(e.target.value)} />
{filtered.map(item => <Item key={item.id} item={item} />)}
</>
);
}
// After: Input stays responsive with useTransition
function FilterableList({ items }) {
const [filter, setFilter] = useState('');
const [deferredFilter, setDeferredFilter] = useState('');
const [isPending, startTransition] = useTransition();
const filtered = useMemo(() =>
items.filter(item =>
item.name.toLowerCase().includes(deferredFilter.toLowerCase())
),
[items, deferredFilter]
);
return (
<>
<input
onChange={e => {
setFilter(e.target.value);
startTransition(() => setDeferredFilter(e.target.value));
}}
/>
<div style={{ opacity: isPending ? 0.6 : 1 }}>
{filtered.map(item => <Item key={item.id} item={item} />)}
</div>
</>
);
}Comparison with Alternatives
| Feature | React 18 Concurrent | Vue 3 Reactivity | Angular Signals | Svelte Reactivity |
|---|---|---|---|---|
| Interruptible rendering | Yes | No | No | No |
| Automatic batching | Yes | Yes | Yes | Yes |
| Suspense boundaries | Yes (data fetching) | Yes (async components) | No (manual) | No |
| Streaming SSR | Yes (Suspense-based) | Yes (unhead) | Yes (Angular Universal) | Yes (SvelteKit) |
| Transition API | useTransition | — | — | — |
| Deferred values | useDeferredValue | — | — | — |
| Learning curve | Higher | Moderate | Moderate | Low |
Advanced Patterns
Nested Suspense Boundaries for Progressive Loading
function Dashboard() {
return (
<div className="dashboard">
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<div className="content">
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel />
</Suspense>
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
</div>
</div>
);
}useTransition with Error Boundaries
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState(null);
function handleSearch(e) {
const value = e.target.value;
setQuery(value);
setError(null);
startTransition(async () => {
try {
const data = await searchAPI(value);
setResults(data);
} catch (err) {
setError(err.message);
}
});
}
return (
<div>
<input value={query} onChange={handleSearch} />
{isPending && <Spinner />}
{error && <ErrorMessage message={error} />}
{!isPending && !error && <ResultsList results={results} />}
</div>
);
}Testing Strategies
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('input stays responsive during expensive filter', async () => {
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`
}));
render(<FilterableList items={items} />);
const input = screen.getByRole('textbox');
await act(async () => {
await userEvent.type(input, 'Item 999');
});
expect(input.value).toBe('Item 999');
expect(screen.getByText(/Item 999/)).toBeInTheDocument();
});
test('suspense shows fallback then content', async () => {
render(<App />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(await screen.findByText('Loaded Content')).toBeInTheDocument();
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});Concurrent Rendering Performance Patterns
Concurrent rendering in React 18 introduces new performance patterns that weren't possible with synchronous rendering. The useDeferredValue hook defers updates to non-critical parts of the UI, keeping the interface responsive while expensive computations run in the background. For example, a search input can update immediately while the results list defers its rendering, preventing input lag during filtering.
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ExpensiveList query={deferredQuery} />
</div>
);
}The startTransition API marks state updates as non-urgent, allowing React to interrupt them if a more urgent update arrives. This is ideal for tab switches, filter changes, and navigation where the user expects immediate feedback on their interaction but can tolerate a brief delay on the resulting content update. Wrap the state update in startTransition to tell React that the update can be interrupted by more urgent interactions like typing or clicking.
Batching in React 18 extends to all event handlers, including timeouts, promises, and native events. Previously, state updates inside setTimeout or promise callbacks triggered separate re-renders. React 18 automatically batches these updates into a single re-render, reducing the total number of renders and improving performance. This automatic batching works in both concurrent and legacy rendering modes.
Error Boundaries and Suspense Integration
Error boundaries interact with Suspense in React 18 to provide comprehensive loading and error handling. Place an error boundary above a Suspense boundary to catch both rendering errors and data fetching errors. When a component inside Suspense throws during rendering or data fetching, the error boundary catches it and displays a fallback UI instead of crashing the entire application.
The useSuspenseQuery pattern from data fetching libraries like TanStack Query integrates directly with Suspense boundaries. Instead of managing loading states with isLoading conditions, the query throws a promise when data is pending, which Suspense catches and shows the fallback. When the data resolves, Suspense re-renders the component with the available data. This pattern eliminates conditional loading logic from components and centralizes loading states in Suspense boundaries.
For production applications, combine Suspense with streaming server-side rendering to progressively send HTML to the client. The server renders the shell immediately and streams in content as Suspense boundaries resolve. This delivers the fastest possible first contentful paint while still providing complete data for each component.
Future Outlook
React 18's concurrent features are the foundation for future React improvements. The React Compiler (React Forget) will automatically memoize components, making concurrent rendering even more effective. Server Components build on Suspense boundaries to reduce client-side JavaScript. The View Transitions API integration will enable smooth page transitions using the browser's native capabilities.
The direction is clear: React is moving toward a model where the framework handles performance optimization automatically, and developers focus on expressing what the UI should look like in each state.
Conclusion
React 18's concurrent features represent a fundamental shift in how React handles rendering. By making rendering interruptible and prioritizable, React 18 enables UIs that stay responsive even under heavy computational loads.
Key takeaways:
- Upgrade to createRoot — this is the gateway to all concurrent features
- Use useTransition for expensive updates — keep the UI responsive during heavy computations
- Use useDeferredValue for derived expensive work — when you do not control the state setter
- Implement Suspense boundaries strategically — at meaningful visual breakpoints, not at the root
- Adopt streaming SSR — for dramatically improved TTFB and LCP
- Leverage automatic batching — fewer re-renders by default, with flushSync to opt out when needed
- Test with concurrent features enabled — catch bugs that only appear under concurrent rendering
Concurrent React is not just a performance feature — it is a new mental model for building responsive UIs. Embrace it, and your applications will feel faster and more fluid than ever before.