React 18 Deep Dive | Concurrent Features, Suspense, Server Components & Hooks
이 글의 핵심
React 18 ships concurrent rendering as a first-class feature. This guide covers every new API — useTransition, useDeferredValue, Suspense boundaries, streaming SSR, and Server Components — with real-world patterns you can apply immediately.
What Changed in React 18
React 18 is the biggest release since React 16 (Hooks). The core change is concurrent rendering — React can now work on multiple renders simultaneously, interrupt low-priority work for urgent updates, and stream UI from server to client.
React 17 (synchronous):
User types → render starts → render blocks main thread → render finishes → UI updates
React 18 (concurrent):
User types (urgent) → render starts for background work
→ user input: pause background, handle typing immediately
→ resume background render
→ UI updates
New features at a glance:
| Feature | What it does |
|---|---|
useTransition | Mark state updates as non-urgent |
useDeferredValue | Defer expensive re-renders |
Suspense (data) | Declarative loading states for async data |
| Streaming SSR | Send HTML chunks as they’re ready |
| Server Components | Zero-bundle components that run on the server |
| Automatic batching | Batch state updates everywhere (not just event handlers) |
Upgrade
npm install react@18 react-dom@18
# If using TypeScript
npm install -D @types/react@18 @types/react-dom@18
// index.tsx — switch to createRoot
import { createRoot } from 'react-dom/client';
import App from './App';
// Before (React 17)
// ReactDOM.render(<App />, document.getElementById('root'));
// After (React 18)
const root = createRoot(document.getElementById('root')!);
root.render(<App />);
Automatic Batching
React 17 only batched state updates inside React event handlers. React 18 batches everywhere — setTimeout, Promises, native event listeners.
// React 17 — NOT batched (causes 2 renders)
setTimeout(() => {
setCount(c => c + 1); // render 1
setFlag(f => !f); // render 2
}, 1000);
// React 18 — automatically batched (1 render)
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// Only 1 re-render
}, 1000);
If you need to opt out (rare):
import { flushSync } from 'react-dom';
flushSync(() => {
setCount(c => c + 1); // Renders immediately
});
setFlag(f => !f); // Renders immediately
useTransition — Non-Urgent Updates
useTransition lets you mark state updates as non-urgent. React will keep the UI responsive to higher-priority interactions while processing the transition in the background.
Without useTransition
// Heavy filter — blocks UI while re-rendering
function SearchList() {
const [query, setQuery] = useState('');
const results = filterItems(items, query); // Expensive computation
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)} // Blocks on every keystroke
/>
<List items={results} />
</>
);
}
With useTransition
import { useState, useTransition } from 'react';
function SearchList() {
const [query, setQuery] = useState('');
const [displayQuery, setDisplayQuery] = useState('');
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // Urgent: update input immediately
startTransition(() => {
setDisplayQuery(value); // Non-urgent: update list in background
});
}
const results = filterItems(items, displayQuery);
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />} {/* Show while transition is in progress */}
<List items={results} />
</>
);
}
Real-World: Tab Navigation
function TabContainer() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
function selectTab(nextTab: string) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<>
<nav>
{['home', 'about', 'contact'].map(t => (
<button
key={t}
onClick={() => selectTab(t)}
style={{ opacity: isPending ? 0.6 : 1 }} // Visual feedback
>
{t}
</button>
))}
</nav>
{/* The previous tab stays visible while next tab loads */}
<Suspense fallback={<Spinner />}>
<TabContent tab={tab} />
</Suspense>
</>
);
}
useDeferredValue — Defer Expensive Renders
useDeferredValue defers updating a value until more urgent updates have processed. Use it when you receive a value from outside (props or context) that drives an expensive render.
import { useState, useDeferredValue, memo } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); // Lags behind query
const isStale = query !== deferredQuery; // True while update is pending
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
{/* Results render with deferred value — won't block typing */}
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<SearchResults query={deferredQuery} />
</div>
</>
);
}
// Wrap in memo so it only re-renders when deferredQuery changes
const SearchResults = memo(function SearchResults({ query }: { query: string }) {
const results = expensiveFilter(items, query);
return <List items={results} />;
});
useTransition vs useDeferredValue:
// useTransition — you control the state update
const [isPending, startTransition] = useTransition();
startTransition(() => setState(newValue));
// useDeferredValue — you receive the value from outside
const deferredValue = useDeferredValue(props.value);
Suspense for Data Fetching
React 18 makes Suspense work for data fetching (not just React.lazy). Components can “suspend” while loading data, and the nearest Suspense boundary shows the fallback.
Basic Pattern
// Framework-integrated data fetching with Suspense
// (Using a Suspense-enabled data fetching library like SWR, React Query, or Next.js)
function UserProfile({ userId }: { userId: number }) {
const user = useUser(userId); // Suspends if not yet loaded
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<Skeleton />}>
<UserProfile userId={1} />
</Suspense>
);
}
Nested Suspense Boundaries
function Dashboard() {
return (
<Suspense fallback={<PageSkeleton />}>
<Header /> {/* Loads fast */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* Independent loading */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<OrdersTable /> {/* Independent loading */}
</Suspense>
</Suspense>
);
}
// RevenueChart and OrdersTable load in parallel, each showing their own skeleton
useTransition + Suspense (Keep Previous UI)
function ProfilePage({ userId }: { userId: number }) {
const [id, setId] = useState(userId);
const [isPending, startTransition] = useTransition();
return (
<>
<UserList
onSelect={newId => startTransition(() => setId(newId))}
/>
{/* While transitioning: old content stays visible (not replaced by fallback) */}
<Suspense fallback={<Spinner />}>
<UserProfile userId={id} />
</Suspense>
</>
);
}
Streaming SSR
React 18 enables streaming HTML from the server — the browser receives and renders HTML chunks as they arrive, rather than waiting for the entire page.
// server.ts (Express + React 18)
import { renderToPipeableStream } from 'react-dom/server';
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(
<App />,
{
bootstrapScripts: ['/client.js'],
onShellReady() {
res.setHeader('Content-Type', 'text/html');
pipe(res); // Start streaming immediately
},
onError(error) {
console.error(error);
res.status(500).send('Server Error');
},
}
);
});
// App with Suspense — slow parts stream in later
function App() {
return (
<html>
<body>
<Nav /> {/* Sent immediately */}
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails /> {/* Streamed when ready */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews /> {/* Streamed when ready */}
</Suspense>
</body>
</html>
);
}
The client receives the shell HTML instantly (fast FCP), then streams in the product and reviews as they load — without waiting for all data before sending anything.
React Server Components
Server Components run exclusively on the server — they have zero impact on the client bundle. Use them for data fetching and database access; use Client Components for interactivity.
Server Components:
- Run on server only
- Can access databases, file system, secrets directly
- Zero bundle size impact
- Cannot use useState, useEffect, event handlers
- Async by default
Client Components:
- Run on client (and pre-rendered on server)
- Can use all React hooks
- Can handle user events
- Add to bundle size
Server Component (default in Next.js App Router)
// app/users/page.tsx — Server Component by default
// No 'use client' directive
async function UsersPage() {
// Direct database access — no API route needed
const users = await db.user.findMany({ orderBy: { name: 'asc' } });
return (
<main>
<h1>Users</h1>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</main>
);
}
export default UsersPage;
Client Component (opt-in with ‘use client’)
'use client'; // This and all imports become client-side
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
Composing Server and Client Components
// app/dashboard/page.tsx — Server Component
async function DashboardPage() {
// Server-side: fetch data, access DB
const data = await fetchDashboardData();
return (
<div>
{/* Server Component — passes data as props */}
<StatsDisplay stats={data.stats} />
{/* Client Component — handles user interaction */}
<FilterBar />
{/* Server Component wrapping a Client Component */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart data={data.revenue} />
</Suspense>
</div>
);
}
// Server Components can pass serializable data to Client Components
// ❌ Cannot pass: functions, class instances, Promises
// ✅ Can pass: strings, numbers, arrays, plain objects, JSX
Server Actions (Next.js 14+)
// app/actions.ts
'use server';
export async function createUser(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
await db.user.create({ data: { name, email } });
revalidatePath('/users');
}
// app/users/new/page.tsx
import { createUser } from '../actions';
export default function NewUserPage() {
return (
<form action={createUser}>
<input name="name" placeholder="Name" />
<input name="email" placeholder="Email" />
<button type="submit">Create</button>
</form>
);
}
New Hooks
useId — Stable IDs for SSR
import { useId } from 'react';
function FormField({ label }: { label: string }) {
const id = useId(); // Stable ID that matches between server and client
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} />
</div>
);
}
useSyncExternalStore — Subscribe to External Stores
import { useSyncExternalStore } from 'react';
function useWindowSize() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
() => ({ width: window.innerWidth, height: window.innerHeight }),
() => ({ width: 0, height: 0 }), // Server snapshot
);
}
function ResponsiveComponent() {
const { width } = useWindowSize();
return <div>Width: {width}px</div>;
}
useInsertionEffect — CSS-in-JS Libraries
import { useInsertionEffect } from 'react';
// For CSS-in-JS library authors — fires before any DOM mutations
function useCSS(rule: string) {
useInsertionEffect(() => {
if (!styleCache.has(rule)) {
styleCache.set(rule, true);
document.head.appendChild(createStylesheet(rule));
}
});
}
Performance Patterns
Virtualization for Long Lists
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Row height estimate
});
return (
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualItem.start}px)`,
height: `${virtualItem.size}px`,
}}
>
{items[virtualItem.index].name}
</div>
))}
</div>
</div>
);
}
Avoid Unnecessary Re-renders
// 1. memo — skip re-render if props didn't change
const ExpensiveChart = memo(function ExpensiveChart({ data }: { data: number[] }) {
return <svg>...</svg>;
});
// 2. useMemo — memoize expensive computations
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.price - b.price),
[items]
);
// 3. useCallback — stable function reference for memo'd children
const handleSelect = useCallback((id: number) => {
setSelectedId(id);
}, []); // Empty deps = stable reference
// 4. State colocation — keep state close to where it's used
// Don't lift state higher than necessary
Summary
| Feature | When to use |
|---|---|
useTransition | Search filters, tab navigation, heavy list updates |
useDeferredValue | Deferred rendering from external values/props |
Suspense boundaries | Parallel data loading, code splitting |
| Streaming SSR | Pages with slow data that should show content fast |
| Server Components | Data fetching, DB access, large dependencies on server |
| Client Components | useState, useEffect, event handlers, browser APIs |
useId | Form labels, ARIA attributes that need SSR stability |
Related posts: