React 19 Server Components in practice
React 19 Server Components in practice
React 19 was released as a stable version on December 5, 2024, marking the framework’s biggest evolution since the introduction of Hooks in 2018. Server Components, long confined to the experimental channel, are now stable and production-ready. This practical guide shows you how to use them concretely in your projects.
Server Components: the paradigm explained
Server Components represent a fundamental change in how React handles rendering. Unlike classic components that execute in the browser, Server Components run exclusively on the server — or at build time — and send zero JavaScript to the client.
What you need to know
A Server Component:
- Has no lifecycle (no
useEffect,useState,useContext) - Has no access to browser APIs (DOM, window, localStorage)
- Can directly access the database, file system, or server environment variables
- Adds zero JavaScript weight to the client bundle
Server Components are the default behavior in compatible frameworks like Next.js 14+. To create a Client Component (the classic behavior), you add the 'use client' directive at the top of the file.
Component tree architecture
A typical pattern combines both types:
// app/dashboard/page.tsx — Server Component (default)
export default async function Dashboard() {
// This call runs on the server, no client-side fetch
const stats = await db.getDashboardStats();
const recentOrders = await db.getRecentOrders();
return (
<main>
<h1>Dashboard</h1>
{/* Server Component: no JS sent to client */}
<StatsGrid stats={stats} />
{/* Client Component: user interaction required */}
<OrderSearchForm />
<OrderTable initialData={recentOrders} />
</main>
);
}
// components/OrderSearchForm.tsx
'use client'; // Required directive for Client Components
import { useState } from 'react';
export function OrderSearchForm() {
const [query, setQuery] = useState('');
// ... interactive logic
}
Actions: the new form handling
React 19 introduces Actions, a standardized way to handle asynchronous operations triggered by users. Gone is the useTransition + useState + error handling boilerplate.
Server Actions with use server
Server Actions are async functions with the 'use server' directive that execute on the server when called from the client:
// app/actions/contact.ts
'use server';
import { z } from 'zod';
const contactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
});
export async function submitContactAction(
prevState: { error?: string; success?: boolean },
formData: FormData
) {
const validated = contactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
});
if (!validated.success) {
return { error: 'Invalid data' };
}
try {
await db.contacts.create(validated.data);
await sendNotificationEmail(validated.data);
return { success: true };
} catch (e) {
return { error: 'Failed to send' };
}
}
Forms with useActionState
The useActionState hook manages form state (pending, errors, return value) in one place:
// components/ContactForm.tsx
'use client';
import { useActionState } from 'react';
import { submitContactAction } from '@/app/actions/contact';
export function ContactForm() {
const [state, action, isPending] = useActionState(
submitContactAction,
{ error: undefined, success: undefined }
);
if (state.success) {
return <p className="success">Message sent successfully!</p>;
}
return (
<form action={action}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" required disabled={isPending} />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required disabled={isPending} />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required disabled={isPending} />
</div>
{state.error && <p className="error">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send'}
</button>
</form>
);
}
useActionState replaces the older useFormState. It accepts an Action and initial state, then returns the current state, the Action to bind to the form, and the isPending boolean.
The useFormStatus hook
To access submission state from a child component of the form, use useFormStatus (from react-dom):
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? (
<span className="spinner" />
) : (
'Submit'
)}
</button>
);
}
This hook is scoped to the nearest form, avoiding prop drilling.
The use() hook: reading resources during render
The use() hook breaks the usual hooks rule: it can be called conditionally and inside loops. It allows reading Promises and Context in a suspendable manner.
With Promises
// Server Component
async function UserProfile({ userId }: { userId: string }) {
const userDataPromise = fetchUserData(userId);
return <UserDetails promise={userDataPromise} />;
}
// Client Component
'use client';
import { use } from 'react';
function UserDetails({ promise }: { promise: Promise<UserData> }) {
// Suspends rendering until the promise resolves
const userData = use(promise);
return (
<div>
<h2>{userData.name}</h2>
<p>{userData.email}</p>
</div>
);
}
With Context
'use client';
import { use, createContext } from 'react';
const ThemeContext = createContext('light');
function ThemedButton({ children }: { children: React.ReactNode }) {
// use() replaces useContext, with conditional support
const theme = use(ThemeContext);
return (
<button className={theme === 'dark' ? 'bg-gray-800' : 'bg-white'}>
{children}
</button>
);
}
useOptimistic: instant user feedback
The useOptimistic hook enables optimistic UI updates: the UI updates immediately before server confirmation, then automatically reverts on error.
'use client';
import { useOptimistic, useTransition } from 'react';
interface Comment {
id: string;
text: string;
author: string;
}
function CommentSection({ initialComments }: { initialComments: Comment[] }) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state, newComment: Comment) => [...state, newComment]
);
const [isPending, startTransition] = useTransition();
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
const tempComment: Comment = {
id: crypto.randomUUID(),
text,
author: 'You',
};
startTransition(async () => {
// Show the comment immediately
addOptimisticComment(tempComment);
// Server call
await addComment({ text });
});
}
return (
<div>
<ul>
{optimisticComments.map(comment => (
<li key={comment.id}>
<strong>{comment.author}:</strong> {comment.text}
</li>
))}
</ul>
<form action={handleSubmit}>
<textarea name="text" required />
<button type="submit" disabled={isPending}>
Comment
</button>
</form>
</div>
);
}
Approach comparison
| Criteria | Classic useState | React Hook Form | React 19 (Actions) |
|---|---|---|---|
| Boilerplate | High | Moderate | Minimal |
| Pending/error handling | Manual | Built-in | Built-in (useActionState) |
| Optimistic updates | Manual | No | Yes (useOptimistic) |
| Validation | External | Built-in + schema | Server-side |
| Re-render performance | Every keystroke | Controlled | Minimal (server-driven) |
| Progressive enhancement | No | No | Yes |
Best practices
-
Keep Server Components as default: only add
'use client'when you need interactivity (state, effects, events). -
Place
'use client'directives at the leaves: parent components remain Server Components as long as they don’t pass callbacks or interactive JSX. -
Always validate server-side: Server Actions don’t replace server validation. Client data is never trustworthy.
-
Use
useOptimisticfor frequent interactions: likes, comments, list updates — anything that benefits from instant feedback.
Conclusion
React 19 represents a major evolution in how we build applications. Server Components reduce JavaScript sent to the client, Actions simplify form handling, and the new hooks (use, useOptimistic) make asynchronous patterns more natural.
The learning curve exists, especially for developers accustomed to the “everything client-side” model. But the benefits in terms of performance, user experience, and code simplicity justify the investment. Combined with a framework like Next.js, these changes pave the way for faster and more accessible web applications.