Programming

Migrating to React Server Components Without Breaking Client State

Tech Setup5 min read
TS

Tech Setup

Published September 30, 2026 · Editorial policy

Migrating to React Server Components Without Breaking Client State

You are staring at a 50,000-line Next.js or Remix codebase where every route uses 'use client' because removing it instantly wipes out sticky UI states, local filters, and optimistic updates. You are a senior frontend engineer tasked with adopting React Server Components to slash bundle sizes, but your first attempt at refactoring a shared dashboard layout broke local storage persistence and threw hydration mismatch errors across half your views. By the end of this guide, you will have a strict mental model and a step-by-step extraction pattern to turn heavily client-bound components into server-rendered layouts without losing a single line of user state.

The Boundary Map: What Stays, What Goes

The primary blocker to RSC adoption is treating the 'use client' directive as a component-level property rather than a boundary marker. When you convert a component to a Server Component, you are not just removing the directive; you are severing its access to browser APIs, React context, and local component memory.

Before touching a single file, map your component tree into three distinct tiers:

  1. Server-Only (RSC): Data fetching, database queries, raw markdown parsing, and heavy dependency usage (e.g., marked, shiki). Zero client bundle impact.
  2. The Client Boundary ('use client'): The absolute minimum leaf nodes that require useState, useEffect, event listeners (onClick), or browser globals (window, localStorage).
  3. The Composition Bridge: Server components that accept client components only as children or props.

The Architectural Antipattern

// ❌ WRONG: Prop-drilling client state through a Server Component
// app/dashboard/page.tsx (Server Component)
import { useState } from 'react'; // Error: Can't use hooks in RSC
import UserDashboard from './UserDashboard';

export default async function Page() {
  const data = await fetchMetrics();
  return <UserDashboard data={data} />;
}

If UserDashboard is marked 'use client' because it handles tab switching via useState, you have just forced the entire data payload and its children back into the client bundle. Instead, invert the composition: fetch on the server, pass data as props to an RSC wrapper, and isolate the interactivity into tiny, leaf-level client islands.

Extracting State Before You Refactor

Never delete 'use client' from a working component until you have extracted its volatile state into a localized leaf node. Let us take a real-world enterprise dashboard widget (MetricCard.tsx) that currently handles its own data fetching, interval polling, and open/closed accordion state.

Step 1: Isolate the Interactive Leaf

Create a new file called MetricCardClient.tsx dedicated solely to UI interaction and local state.

// components/MetricCardClient.tsx
'use client';

import { useState } from 'react';

interface MetricCardClientProps {
  title: string;
  value: string | number;
  children: React.ReactNode; // Slot for server-rendered content
}

export function MetricCardClient({ title, value, children }: MetricCardClientProps) {
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
      <div className="flex items-center justify-between">
        <h3 className="text-sm font-medium text-zinc-400">{title}</h3>
        <span className="text-xl font-bold text-zinc-100">{value}</span>
      </div>
      <button
        onClick={() => setIsExpanded(!isExpanded)}
        className="mt-3 text-xs text-indigo-400 hover:text-indigo-300"
      >
        {isExpanded ? 'Hide Details' : 'View Details'}
      </button>
      {isExpanded && <div className="mt-4 border-t border-zinc-800 pt-3">{children}</div>}
    </div>
  );
}

Step 2: Convert the Parent to a Server Component

Now, refactor the original file into a pure Server Component (MetricCard.tsx). Remove 'use client', import your database client or fetch utility directly, and pass the heavy markup down as children.

// components/MetricCard.tsx (Server Component)
import { db } from '@/lib/db';
import { MetricCardClient } from './MetricCardClient';

interface MetricCardProps {
  metricId: string;
}

export async function MetricCard({ metricId }: MetricCardProps) {
  // Direct database query on the server—zero client bundle footprint
  const metric = await db.metrics.findUnique({ where: { id: metricId } });
  
  if (!metric) return null;

  // Heavy computations or formatting can happen here
  const formattedValue = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(metric.amount);

  return (
    <MetricCardClient title={metric.name} value={formattedValue}>
      <p className="text-sm text-zinc-300">{metric.description}</p>
      <span className="text-xs text-zinc-500">Last updated: {metric.updatedAt.toISOString()}</span>
    </MetricCardClient>
  );
}

By passing the detailed breakdown paragraph as children from the Server Component to the Client Component, you avoid importing heavy markdown parsers or formatting libraries into the browser runtime.

Managing Global Context Across the Server-Client Divide

One of the most frequent crash points during RSC migration is React Context. If you wrap your entire application layout in a <ThemeContext.Provider> or <AuthContext.Provider>, Next.js or your bundler will throw an error if a Server Component attempts to consume it or if the provider itself is not explicitly marked as a client boundary.

The Provider Boundary Pattern

Never place context providers at the root of a layout unless that layout is a client component. Instead, push context providers as deep into the tree as possible, acting as a wrapper around the interactive subtree.

// app/dashboard/DashboardLayout.tsx (Server Component)
import { getSession } from '@/lib/auth';
import { DashboardClientProvider } from './DashboardClientProvider';
import { Sidebar } from './Sidebar';
import { MainContent } from './MainContent';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  // Fetch session server-side securely
  const session = await getSession();

  return (
    <DashboardClientProvider initialUser={session?.user}>
      <div className="flex h-screen bg-zinc-950 text-zinc-100">
        <Sidebar />
        <main className="flex-1 overflow-y-auto p-6">{children}</main>
      </div>
    </DashboardClientProvider>
  );
}

And the corresponding client provider that manages local state initialization using server-provided props:

// app/dashboard/DashboardClientProvider.tsx
'use client';

import { createContext, useContext, useState } from 'react';

interface User {
  id: string;
  name: string;
  role: string;
}

const UserContext = createContext<User | null>(null);

export function DashboardClientProvider({
  initialUser,
  children,
}: {
  initialUser: User | null;
  children: React.ReactNode;
}) {
  const [user] = useState<User | null>(initialUser);

  return (
    <UserContext.Provider value={user}>
      {children}
    </UserContext.Provider>
  );
}

export const useUser = () => useContext(UserContext);

This pattern ensures that while your state management (useState, Context API) remains fully functional on the client, the initial state is hydrated directly from secure server operations without requiring an extra client-side useEffect data-fetching waterfall.

Troubleshooting Hydration Mismatches and Serialization Errors

When shifting state boundaries, runtime errors often spike. Here are the three most common failures during RSC migration and their exact resolutions.

1. "Functions cannot be passed to Client Components"

  • The Symptom: You pass a database query function, a server action, or a utility function as a prop from a Server Component to a Client Component.
  • The Fix: React props crossing the server-client boundary must be serializable via the React Server Components wire format (JSON-serializable). Remove functions from props. If you need to trigger a server mutation from a client component, pass a Server Action reference (imported via 'use server'), or pass data values, never utility code.

2. Hydration Mismatch on localStorage or Date.now()

  • The Symptom: Text content rendered on the server differs from the initial client render because the client reads localStorage.getItem('theme') or new Date().
  • The Fix: Defer client-only state initialization to useEffect, or use CSS variables injected by the server to handle visual toggles before JavaScript loads.
// ❌ WRONG: Causes hydration mismatch
'use client';
export function ThemeToggle() {
  const [theme, setTheme] = useState(localStorage.getItem('theme') || 'dark');
  return <button onClick={() => {}}>{theme}</button>;
}

// ✅ CORRECT: Safe initialization with useSyncExternalStore or useEffect
'use client';
import { useState, useEffect } from 'react';

export function ThemeToggle() {
  const [theme, setTheme] = useState('dark'); // Safe default matching server output
  
  useEffect(() => {
    const saved = localStorage.getItem('theme');
    if (saved) setTheme(saved);
  }, []);

  return <button onClick={() => {}}>{theme}</button>;
}

3. Missing 'use client' on Third-Party UI Libraries

  • The Symptom: Importing a component from a library like lucide-react, radix-ui, or framer-motion inside an RSC throws an error stating that hooks are not supported.
  • The Fix: Many older component libraries do not include 'use client' directives in their entry points. Wrap them in your own local client component boundary:
// components/ui/Tooltip.tsx
'use client';
export { Tooltip, TooltipTrigger, TooltipContent } from '@radix-ui/react-tooltip';

Verification: Auditing Your Bundle Size

Never trust code structure alone. You must verify that your migration actually reduced client-side JavaScript execution. Run your production build analyzer to inspect the client bundle manifest.

Execute the following build and analysis pipeline in your terminal:

# Clear previous cache to avoid false build artifacts
rm -rf .next/

# Run production build with bundle analyzer flags enabled (Next.js example)
ANALYZE=true npm run build

Expected output in your terminal and browser report:

Route (app)                              Size     First Load JS
┌ ○ /dashboard                           2.4 kB        84.5 kB
├ ○ /dashboard/settings                  1.1 kB        82.1 kB
└ λ /api/auth/[...nextauth]                 0 kB           0 kB

+ First Load JS shared by all            80.2 kB
  ├ chunks/framework-abc123.js           45.1 kB
  ├ chunks/main-def456.js                31.2 kB
  └ other shared chunks                  3.9 kB

If your First Load JS for /dashboard drops significantly while retaining all interactive filters and stateful components, your boundary mapping is correct.

To maintain this balance as your codebase scales, enforce a strict rule: every time a developer adds 'use client', they must document why standard RSC composition or server-side data streaming could not solve the problem. Keep your interactive boundaries as small as atomic UI primitives, and let the server handle everything else.