Composing Components Across the Client-Server Divide

React’s mental model has always been a top-down data flow: a component receives props from its parent and renders its children. But what happens when the data a component needs is locked to a particular side of the client-server boundary?

Consider a greeting component that reads a color from a file on one computer—the server—while also managing an editable useState input on another computer—the client. A naive implementation that tries to do both in a single component is impossible. The readFile call can only run where the file exists, and useState only has a meaningful value where the browser runs.

The way out is to split the component.

Two Halves of a Whole

The first part handles the data loading. This is the “backend” component—call it GreetingBackend—which is responsible only for reading the file and passing the result down as a color prop.

import { useState } from 'react';
import { readFile } from 'fs/promises';
 
async function ImpossibleGreeting() {
  const [yourName, setYourName] = useState('Alice');
  const myColor = await readFile('./color.txt', 'utf8');
  return (
    <>
      <input placeholder="What's your name?"
        value={yourName}
        onChange={e => setYourName(e.target.value)}
      />
      <p style={{ color: myColor }}>
        Hello, {yourName}!
      </p>
    </>
  );
}

The second part, GreetingFrontend, receives that color prop and owns the interactive form, including the useState hook that tracks the name being typed.

import { readFile } from 'fs/promises';
import { GreetingFrontend } from './client';
 
async function GreetingBackend() {
  const myColor = await readFile('./color.txt', 'utf8');
  return <GreetingFrontend color={myColor} />;
}

The key insight is that the backend runs first. We aren’t modeling “the frontend loads data from the backend.” We’re modeling “the backend passes data to the frontend.” This preserves React’s top-down data flow while inverting where the data originates. Because the backend is the source of truth, it must be the parent of the frontend component.

'use client';
 
import { useState } from 'react';
 
export function GreetingFrontend({ color }) {
  const [yourName, setYourName] = useState('Alice');
  return (
    <>
      <input placeholder="What's your name?"
        value={yourName}
        onChange={e => setYourName(e.target.value)}
      />
      <p style={{ color }}>
        Hello, {yourName}!
      </p>
    </>
  );
}

Together, these two parts form a single encapsulated abstraction—one that spans both worlds. The data flows in one direction: from the backend to the frontend, from the server to the client.

Local Data, Local State

This pattern’s strength shows when you start composing these split components. Rendering <GreetingBackend> multiple times gives you multiple isolated instances—each input is independently editable, and each backend instance independently loads its own data.

Make the backend take a colorFile prop, and you can render three greetings that each read different files without any cross-instance coordination.

import { readFile } from 'fs/promises';
import { GreetingFrontend } from './client';
 
async function GreetingBackend() {
  const myColor = await readFile('./color.txt', 'utf8');
  return <GreetingFrontend color={myColor} />;
}

Each backend “knows” how to load its own data. Each frontend “knows” how to manage its own state. And each backend renders a frontend, which makes the backend tag feel like a self-contained unit—a piece of the backend with its own attached piece of the frontend.

You can substitute any backend-only resource for file reads: an ORM query, a secret API key, an internal microservice. Likewise, the input represents any interactivity. The point is that both sides can be composed into a single self-contained component without manual plumbing of data or state.

Because the backend side runs first, from the frontend’s perspective the data is already there when the page loads. No loading spinners, no partial states—just props flowing down from the backend.

It’s Not About the HTML

This is where the model diverges from simply rendering server-side HTML. The props passed from the backend aren’t only used to generate initial markup—they can be used later, inside event handlers.

Tweak the frontend to set document.body.style.backgroundColor from the color prop, but only while the input is focused. Typing into the field changes the page background live.

'use client';
 
import { useState } from 'react';
 
export function GreetingFrontend({ color }) {
  const [yourName, setYourName] = useState('Alice');
  return (
    <>
      <input placeholder="What's your name?"
        value={yourName}
        onChange={e => setYourName(e.target.value)}
      />
      <p style={{ color }}>
        Hello, {yourName}!
      </p>
    </>
  );
}

This works because the backend sends props to the frontend for use at any point in the component’s life, not just during initial render. You can pass strings, numbers, booleans, objects, or pieces of JSX—anything that can be serialized over the wire.

Data from the Server, Sorting on the Client

Consider a sortable file list. The list of files comes from readdir, which only exists on the server. The sort order is tracked by useState, which only exists on the client.

<GreetingBackend />

The fix follows the same pattern: split out a backend component that reads the directory and passes the items down, and a frontend component that owns the interactive sorting logic.

<>
  <GreetingBackend />
  <GreetingBackend />
  <GreetingBackend />
</>

Once the items are an array in a frontend prop, the component can be extended with filtering logic that reacts to every keystroke. And because it’s reusable, the same SortableList component can be pointed at a different data source, such as a directory of dependencies, without changes to the frontend.

An Expanding Preview

Take a static preview card for a blog post. On its own, a PostPreview can load its own data on the server—title, word count, and an excerpt. But adding a click-to-expand behavior requires state and event handlers on the client.

The solution is to extract the interactive part into a ExpandingSection component that lives in the client world. It accepts children and an extraContent prop. The backend fills those holes with content before passing the whole section down.

The extraContent prop arrives as part of the initial payload. Clicking the card to expand it makes no new network requests—the content is already there. Props flow down from the backend to the frontend, through layers of composition, and state is managed locally wherever it must live.

This weaving pattern—leaving holes in a frontend component and filling them from the backend—is the standard way to nest server data inside interactive client components. It’s the only way to do it, so it pays to get comfortable with it.

Sortable, Filterable Posts

The full potential emerges when you combine these patterns. A list of post previews loaded on the server can be rendered inside a reusable client-side sortable list.

The SortableList component doesn’t care what its items are—they can be strings or objects shaped like { id, content, searchText }. The individual PostPreview items each load their own data and manage their own expanded state.

The result is a fully interactive tree where you can expand cards, filter by text input, and flip the order—all without any extra round trips to the server. The entire page’s data is collected in a single server run and delivered as one payload. Only the props the frontend actually uses are sent.

Each component in the tree encapsulates its own data logic on the server side and its own state logic on the client side. You can add more encapsulated logic at any point in the tree, as long as you place it in the correct world.

The Feedback Loop

Users don’t think in terms of “frontend” and “backend.” They see a section, a header, a post preview, a sortable list. Self-contained components—even ones that straddle two machines—speak that language.

Server Components can load their own data. Client Components can manage their own state. Put them together and you get composable abstractions that run where they need to. The split between the two worlds is physical and unavoidable, but one side doesn’t have to dominate the other.

The terminology takes some getting used to—especially for those who assume “client loads from server” rather than “server renders client.” But once the model clicks, a component with self-contained data loading and stateful logic doesn’t have to compromise. When we can compose across the stack, any piece of UI can have its own backend needs and its own frontend needs, Snap together the pieces, and the boundary disappears from the developer’s view.