React 18 & 19: Concurrent Features, Suspense, and Server Components
March 21, 2025 · 9 min read
React, React 18, React 19, Server Components, Concurrent React, Interview Prep
Introduction
React 18 and 19 represent the biggest shifts in React's architecture since hooks. Concurrent rendering, Suspense for data fetching, and Server Components fundamentally change how we build React applications. These topics are increasingly common in senior-level interviews.
Concurrent Rendering: The Foundation
Concurrent rendering allows React to interrupt, pause, and resume renders. This enables keeping the UI responsive even during heavy updates.
Why It Matters
Before React 18, renders were synchronous - once started, they blocked until complete. A slow component blocked everything:
// React 17: Blocking render
function App() {
const [query, setQuery] = useState('');
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)} // Triggers render
/>
<SlowList query={query} /> {/* Blocks input until complete */}
</div>
);
}
With concurrent rendering, React can pause SlowList to handle the input update first.
Suspense for Data Fetching
Suspense lets components "wait" for async data with declarative loading states.
Basic Suspense Pattern
import { Suspense } from 'react';
function App() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<Spinner />}>
<UserProfile /> {/* Can "suspend" while loading */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
</div>
);
}
Using Suspense with React Query
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
// This component "suspends" until data is ready
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// No loading check needed - data is guaranteed to exist
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// Parent handles loading state
function App() {
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={1} />
</Suspense>
);
}
Nested Suspense Boundaries
function Dashboard() {
return (
<div className="dashboard">
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<div className="content">
{/* Independent loading states */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Suspense fallback={<MainSkeleton />}>
<MainContent>
{/* Nested suspense for detailed loading */}
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
</MainContent>
</Suspense>
</div>
</div>
);
}
Suspense Strategy
Place Suspense boundaries based on user experience, not component structure. Users should see meaningful partial content quickly, not a single loading spinner for the entire page.
Suspense Strategy in Practice
Building Glucoplate, I learned to place Suspense boundaries based on API response times. Fast data shows first, slower calculations load independently. Users see meaningful content quickly instead of waiting for the slowest component. Plan your boundaries around user experience, not component structure.
React Server Components (RSC)
Server Components are React components that render on the server only, sending HTML (not JavaScript) to the client. This is a paradigm shift.
The Mental Model
// Server Component (default in Next.js App Router)
// - Runs on server only
// - Can access database, file system directly
// - Zero client-side JavaScript
// - Cannot use useState, useEffect, or event handlers
async function ProductList() {
// Direct database query - no API needed!
const products = await db.products.findMany();
return (
<ul>
{products.map(product => (
<li key={product.id}>
<ProductCard product={product} />
</li>
))}
</ul>
);
}
Client vs Server Components
// server-component.jsx (default)
// Can: fetch data, access backend, read files
// Cannot: useState, useEffect, onClick, browser APIs
async function ServerComponent() {
const data = await fetchFromDatabase();
return <div>{data.title}</div>;
}
// client-component.jsx
'use client'; // This directive makes it a Client Component
import { useState } from 'react';
function ClientComponent() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
Composing Server and Client Components
// ProductPage.jsx (Server Component)
import { AddToCartButton } from './AddToCartButton'; // Client Component
async function ProductPage({ productId }) {
// Server: fetch product data
const product = await db.products.findUnique({ where: { id: productId } });
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
{/* Client Component for interactivity */}
<AddToCartButton productId={product.id} />
</div>
);
}
// AddToCartButton.jsx (Client Component)
'use client';
import { useState } from 'react';
import { addToCart } from './actions';
export function AddToCartButton({ productId }) {
const [isAdding, setIsAdding] = useState(false);
const handleClick = async () => {
setIsAdding(true);
await addToCart(productId);
setIsAdding(false);
};
return (
<button onClick={handleClick} disabled={isAdding}>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
);
}
When to Use Which
| Server Components | Client Components |
|---|---|
| Data fetching | Event handlers (onClick, onChange) |
| Backend access | useState, useEffect |
| Sensitive logic | Browser APIs |
| Static content | Real-time updates |
| Large dependencies (they stay on server) | Refs, context consumers |
Common RSC Mistake
You cannot import a Server Component into a Client Component. The flow is always Server → Client, never Client → Server. If you need server data in a client component, fetch it in a parent Server Component and pass it as props.
Server Actions
Server Actions let you call server functions directly from client components - no API routes needed.
// actions.js
'use server';
export async function createTodo(formData) {
const title = formData.get('title');
await db.todos.create({
data: { title, completed: false }
});
revalidatePath('/todos');
}
export async function deleteTodo(id) {
await db.todos.delete({ where: { id } });
revalidatePath('/todos');
}
// TodoForm.jsx (Client Component)
'use client';
import { createTodo } from './actions';
export function TodoForm() {
return (
<form action={createTodo}>
<input name="title" placeholder="New todo..." />
<button type="submit">Add</button>
</form>
);
}
Server Actions with useActionState (React 19)
'use client';
import { useActionState } from 'react';
import { createUser } from './actions';
function SignupForm() {
const [state, formAction, isPending] = useActionState(createUser, null);
return (
<form action={formAction}>
<input name="email" type="email" />
<input name="password" type="password" />
{state?.error && <p className="error">{state.error}</p>}
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Sign Up'}
</button>
</form>
);
}
React 19 Features
The React Compiler
React 19 introduces an automatic compiler that handles memoization:
// Before: Manual memoization
function ProductList({ products, onSelect }) {
const sortedProducts = useMemo(
() => [...products].sort((a, b) => a.price - b.price),
[products]
);
const handleSelect = useCallback(
(id) => onSelect(id),
[onSelect]
);
return (
<ul>
{sortedProducts.map(product => (
<MemoizedItem
key={product.id}
product={product}
onSelect={handleSelect}
/>
))}
</ul>
);
}
// After: React Compiler handles it automatically
function ProductList({ products, onSelect }) {
const sortedProducts = [...products].sort((a, b) => a.price - b.price);
return (
<ul>
{products.map(product => (
<Item
key={product.id}
product={product}
onSelect={(id) => onSelect(id)}
/>
))}
</ul>
);
}
// Compiler automatically memoizes what needs memoizing
Compiler Impact
The React Compiler analyzes your code and automatically inserts memoization. Early reports show 30-60% reduction in unnecessary re-renders without any code changes. However, it doesn't fix algorithmic issues - if your filter is O(n²), it's still slow.
use() Hook
The new use hook can read promises and context:
import { use, Suspense } from 'react';
function Comments({ commentsPromise }) {
// use() unwraps the promise - suspends until resolved
const comments = use(commentsPromise);
return (
<ul>
{comments.map(comment => (
<li key={comment.id}>{comment.text}</li>
))}
</ul>
);
}
// Parent creates the promise
function Article({ articleId }) {
const commentsPromise = fetchComments(articleId);
return (
<article>
<Content id={articleId} />
<Suspense fallback={<CommentsSkeleton />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
</article>
);
}
Interview Questions
Q1: What is concurrent rendering and why does it matter?
Answer: Concurrent rendering allows React to interrupt, pause, and resume renders. This matters because:
- Responsiveness: Heavy renders don't block user input
- Prioritization: Urgent updates (typing) processed before non-urgent (filtering results)
- Better UX: No more frozen UIs during expensive operations
It's enabled by default in React 18 for features like useTransition and Suspense.
Q2: Explain the difference between Server and Client Components.
Answer:
Server Components:
- Render on server, send HTML to client
- Zero JavaScript bundle impact
- Can access databases, file systems directly
- Cannot use hooks, event handlers, or browser APIs
Client Components:
- Marked with
'use client' - Include JavaScript in the bundle
- Can use useState, useEffect, event handlers
- Run in the browser
The composition rule: Server Components can render Client Components, but not vice versa.
Q3: When would you use useTransition vs useDeferredValue?
Answer:
- useTransition: When you control the state update. Wrap
setStateinstartTransition(). - useDeferredValue: When receiving a value as a prop. Wrap the value itself.
// useTransition - you own the setState
const [isPending, startTransition] = useTransition();
startTransition(() => setSearchResults(filtered));
// useDeferredValue - you receive a prop
const deferredQuery = useDeferredValue(props.query);
Q4: What are the benefits of Server Actions?
Answer:
- No API boilerplate: Call server functions directly
- Type safety: Full TypeScript support across client/server boundary
- Progressive enhancement: Forms work without JavaScript
- Automatic revalidation: Easy cache invalidation with
revalidatePath - Security: Server-only code never exposed to client
Common Mistakes
1. Using Hooks in Server Components
// ❌ Server Component with useState
async function ProductList() {
const [sort, setSort] = useState('price'); // Error!
const products = await db.products.findMany();
return <ul>...</ul>;
}
// ✅ Make it a Client Component or lift state
'use client';
function ProductList() {
const [sort, setSort] = useState('price');
// ...
}
2. Importing Server Components into Client Components
// ❌ Cannot import Server Component into Client
'use client';
import { ServerComponent } from './ServerComponent'; // Error!
// ✅ Pass Server Components as children
'use client';
function ClientWrapper({ children }) {
return <div className="wrapper">{children}</div>;
}
// In a Server Component:
<ClientWrapper>
<ServerComponent /> {/* Passed as children - works! */}
</ClientWrapper>
3. Forgetting Suspense Boundaries
// ❌ useSuspenseQuery without Suspense = crash
function App() {
return <UserProfile />; // Suspends with no boundary!
}
// ✅ Always wrap suspending components
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
);
}
Summary
| Feature | Purpose | Key Point |
|---|---|---|
| Concurrent Rendering | Interruptible renders | Foundation for all React 18+ features |
| useTransition | Mark updates as non-urgent | Keeps input responsive |
| useDeferredValue | Defer prop updates | For values you don't control |
| Suspense | Declarative loading states | Place boundaries strategically |
| Server Components | Server-only rendering | Zero JS, direct backend access |
| Server Actions | Server functions from client | No API routes needed |
| React Compiler | Automatic memoization | Coming in React 19 |
These features represent React's evolution from a client-side library to a full-stack framework. Understanding them is essential for modern React development.
Part 11 of the React Developer Reference series.