Ch. 3 · React

React Server Components and the Client Boundary

Split server and client components, mark interactive leaves with use client, and keep props serializable across the boundary.

~2 min readadvancedupdated Oct 5, 2026

React Server Components run on the server and stream HTML to the client. They can read data and use server-only libraries directly, but they cannot use state, effects or browser APIs. The 'use client' directive marks the file where the tree becomes interactive, and that boundary has rules about what can cross it.

Before you start

You should be comfortable with React components, props and hooks, and know the difference between rendering on the server and running in the browser. This article assumes a framework that supports the server/client split.

Step-by-step walkthrough

Step 1: Fetch in server components

A server component can be async, so it awaits data and renders the result without a loading state or an effect. Because it never ships to the browser, it can import database clients and secrets that a client bundle must not contain.

Step 2: Mark interactive leaves with ‘use client’

Put 'use client' at the top of the file that needs useState, event handlers or effects. Everything it imports becomes part of the client bundle, so keep the boundary as low in the tree as possible and pass server-rendered children into client wrappers.

Step 3: Keep props serializable

Props crossing into a client component must be serializable: plain data, arrays, and possibly promises. Functions, class instances and symbols cannot cross unless they are server actions. Design the boundary around data, not callbacks.

Worked scenario

The server component fetches and renders, and a small client leaf handles the interaction.

// PostList.tsx — server component (no directive)
import { LikeButton } from './LikeButton';

export async function PostList() {
  const posts = await getPosts();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          {post.title}
          <LikeButton id={post.id} />
        </li>
      ))}
    </ul>
  );
}
TSX

Walk through the example

PostList awaits getPosts() on the server and emits the list markup. LikeButton is a client component because it reacts to clicks, and it receives only the serializable id. The server component never enters the client bundle, while the button does, which is the intended split.

Common mistake

Calling useState or useEffect in a server component, which throws, or passing a function prop across the boundary, which is not serializable. Another mistake is putting 'use client' at the app root, which turns the whole tree into a client bundle and loses the benefit.

Verify the behavior

Build the app and inspect the client bundle to confirm the data-fetching dependencies are absent. Add a client component far down the tree and assert it still hydrates. Pass a non-serializable value and read the framework’s error, then replace it with plain data.

Interview exercise

A server component needs a click handler on a row. Where does the handler go?

Answer and reasoning

The handler must live in a client component, because event handlers require the browser. Keep the server component for the data and rows, and extract the interactive part into a small 'use client' child that receives serializable props such as an id. This keeps the boundary narrow and the bundle small.

Continue learning

Compare fetching patterns in React request races and React suspense boundaries. Read the React Server Components guide and try the React interview questions.

More in React

read ✓React · hard

React Context Splitting for Performance

Stop a single context value from re-rendering every consumer by splitting state and dispatch and memoizing the provider value.

~2 min readread →
read ✓React · mid

React lazy Loading and Code Splitting

Split bundles with lazy and Suspense, work with default exports, and place boundaries so a chunk loads only when needed.

~2 min readread →
esc