Back to blog

React 19 Server Components in practice

January 20, 2026 Dedimarco
react javascript web server-components
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

CriteriaClassic useStateReact Hook FormReact 19 (Actions)
BoilerplateHighModerateMinimal
Pending/error handlingManualBuilt-inBuilt-in (useActionState)
Optimistic updatesManualNoYes (useOptimistic)
ValidationExternalBuilt-in + schemaServer-side
Re-render performanceEvery keystrokeControlledMinimal (server-driven)
Progressive enhancementNoNoYes

Best practices

  1. Keep Server Components as default: only add 'use client' when you need interactivity (state, effects, events).

  2. Place 'use client' directives at the leaves: parent components remain Server Components as long as they don’t pass callbacks or interactive JSX.

  3. Always validate server-side: Server Actions don’t replace server validation. Client data is never trustworthy.

  4. Use useOptimistic for 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.