Skip to main content
react

React Server Components Make Bad Boundaries Painful

RSC does not invent the problem of bad component boundaries — it just makes you pay for them immediately, at the architecture level.


5 min read

React Server Components do not change the rules of good component design. They enforce them. When you colocate data fetching with interactivity in the same component, you get a use client directive that opts the entire subtree out of server rendering — including children that had no business being interactive in the first place. The streaming benefits, the zero-bundle data fetching, the server-side access to databases — all of it goes away below that boundary.


What a Good RSC Boundary Looks Like

The useful mental model: server components are data containers, client components are interaction layers. A server component fetches data, composes layout, and passes serializable props down. A client component receives those props and handles events, animations, and browser APIs.

// server component — fetches, composes, passes serializable data down
// app/projects/page.tsx (no directive — server by default)
import { ProjectCard } from "./project-card";
import { db } from "@/lib/db";

export default async function ProjectsPage() {
  const projects = await db.project.findMany({ orderBy: { updatedAt: "desc" } });

  return (
    <ul>
      {projects.map((p) => (
        <ProjectCard key={p.id} id={p.id} title={p.title} status={p.status} />
      ))}
    </ul>
  );
}
// client component — handles interaction only
// app/projects/project-card.tsx
"use client";

type Props = { id: string; title: string; status: string };

export function ProjectCard({ id, title, status }: Props) {
  const [expanded, setExpanded] = useState(false);

  return (
    <li>
      <button onClick={() => setExpanded((v) => !v)}>{title}</button>
      {expanded && <ProjectDetails id={id} />}
    </li>
  );
}

The boundary is clean: no database access in the client, no useState in the server. Data flows down as props. The server component never re-renders on the client.

Common Boundary Mistakes

Fetching inside a client component. If your client component calls fetch() or a data access layer directly, you lose the streaming model. The page cannot render that subtree until the data arrives on the client, after hydration. You also expose your data access to the browser bundle.

Adding use client to a component because one child needs it. This is the most common mistake. If a layout component wraps a chart, and the chart needs useRef, the reflex is to add "use client" to the layout. Instead, extract the chart into its own client component and import it. The layout stays on the server.

Passing non-serializable data across the boundary. Server components can only pass props that survive serialization: strings, numbers, plain objects, arrays. Functions, class instances, and React elements (usually) cannot cross. If you find yourself trying to pass a function from server to client, that is a sign the interaction belongs in the client layer.

Before and After: The Dashboard Card Refactor

Before — a client component that fetches its own data:

"use client";

export function RevenueCard() {
  const [revenue, setRevenue] = useState<number | null>(null);

  useEffect(() => {
    fetch("/api/revenue/summary")
      .then((r) => r.json())
      .then((d) => setRevenue(d.total));
  }, []);

  const [showBreakdown, setShowBreakdown] = useState(false);

  return (
    <div>
      <p>{revenue ?? "Loading..."}</p>
      <button onClick={() => setShowBreakdown((v) => !v)}>Details</button>
      {showBreakdown && <RevenueBreakdown />}
    </div>
  );
}

This component: (1) fetches client-side, adding a waterfall after hydration, (2) is entirely client, so the initial HTML is empty, (3) mixes data fetching with interaction in a single unit.

After — server component fetches, client component handles toggling:

// server
import { RevenueToggle } from "./revenue-toggle";
import { getRevenueSummary } from "@/lib/revenue";

export async function RevenueCard() {
  const { total, breakdown } = await getRevenueSummary();

  return <RevenueToggle total={total} breakdown={breakdown} />;
}
// client
"use client";

type Props = { total: number; breakdown: LineItem[] };

export function RevenueToggle({ total, breakdown }: Props) {
  const [open, setOpen] = useState(false);

  return (
    <div>
      <p>{total}</p>
      <button onClick={() => setOpen((v) => !v)}>Details</button>
      {open && <BreakdownList items={breakdown} />}
    </div>
  );
}

The server component fetches directly from the data layer — no HTTP round-trip, no API route needed for internal data. The HTML arrives with the number already in it. The client component is now a pure interaction shell.

The Practical Heuristic

Before adding "use client", ask: does this component need to respond to user events, read browser APIs, or hold state? If the answer is no, it should stay on the server even if it is deep in the tree. RSC lets server components be grandchildren of client components — the direction is not one-way from top to bottom. Use children to thread server-rendered subtrees through client boundaries when needed.

The discomfort RSC creates around "use client" is intentional. It is the framework asking you to separate your concerns.