React Server Components change the fundamental model of how React works: components can be async, access databases directly, and send only necessary HTML and data to the browser. Claude Code generates RSC patterns correctly — understanding the server/client boundary, the rules around component composition, and how server actions integrate with optimistic updates.
This guide covers React Server Components with Claude Code: the composition model, data fetching patterns, the use() hook, server actions, and common mistakes.
The Server/Client Boundary
I keep getting "You're importing a component that needs useState.
It only works in a Client Component but none of its parents are marked with 'use client'."
Explain the mental model and fix my component.
The key insight: RSC is a tree where server nodes render first, then client nodes hydrate. The rule:
- Server components: can be
async, access DB, run server-only code, import server-only packages - Client components: can use
useState,useEffect, browser APIs, event handlers - You can pass server-rendered children INTO client components (they don’t become client components)
- You CANNOT import a server component from a client component
// ❌ Wrong — trying to use useState in a server component
export default async function ProductPage({ id }: { id: string }) {
const product = await db.products.findOne(id);
const [quantity, setQuantity] = useState(1); // ERROR: useState in server component
return <div>{product.name}</div>;
}
// ✅ Correct — split at the boundary
// Server component handles data fetching
export default async function ProductPage({ id }: { id: string }) {
const product = await db.products.findOne(id);
return (
<div>
<h1>{product.name}</h1>
<p>${(product.priceCents / 100).toFixed(2)}</p>
{/* Pass serializable props to client component */}
<AddToCartButton productId={product.id} productName={product.name} />
</div>
);
}
// Client component handles interactivity
'use client';
export function AddToCartButton({ productId, productName }: { productId: string; productName: string }) {
const [quantity, setQuantity] = useState(1);
const [adding, setAdding] = useState(false);
return (
<div>
<input type="number" value={quantity} onChange={e => setQuantity(Number(e.target.value))} min={1} />
<button onClick={() => handleAddToCart(productId, quantity, setAdding)}>
{adding ? 'Adding...' : 'Add to Cart'}
</button>
</div>
);
}
Passing Server-Rendered Children to Client Components
The mental model:
// ✅ This works! Server component passed as children to client wrapper
// The ProductList renders on server, ClientWrapper hydrates separately
async function ProductsPage() {
const products = await db.products.findAll();
return (
// ClientWrapper is client component with useState for filtering
<ClientWrapper>
{/* These render on server — ClientWrapper receives them as finished JSX */}
{products.map(p => (
<ProductCard key={p.id} product={p} /> // Server component
))}
</ClientWrapper>
);
}
// ClientWrapper receives pre-rendered server children
'use client';
function ClientWrapper({ children }: { children: React.ReactNode }) {
const [filter, setFilter] = useState('all');
return (
<div>
<FilterBar value={filter} onChange={setFilter} />
{/* children are already rendered server HTML — no re-render on filter change */}
{children}
</div>
);
}
Async Data Fetching Patterns
How should I handle loading states and errors in Server Components?
I have a page that fetches user profile and recent orders —
they load at different speeds.
// src/app/profile/page.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
// Top-level page — no loading state here (loading.tsx handles that)
export default function ProfilePage() {
return (
<div>
<h1>Your Profile</h1>
{/* Each section owns its loading and error state */}
<ErrorBoundary fallback={<ProfileError />}>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<OrdersError />}>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</ErrorBoundary>
</div>
);
}
// Each data component is async — fetches its own data
async function UserProfile() {
const user = await getAuthenticatedUser(); // Cached with React's cache()
const profile = await db.profiles.findOne(user.id);
return (
<section>
<img src={profile.avatar} alt={profile.name} />
<h2>{profile.name}</h2>
<p>{profile.bio}</p>
</section>
);
}
async function RecentOrders() {
const user = await getAuthenticatedUser(); // Same cached value — not a second DB hit
const orders = await db.orders.findByUser(user.id, { limit: 5 });
return (
<section>
{orders.map(order => <OrderRow key={order.id} order={order} />)}
</section>
);
}
// React's cache() deduplicates: same args = same promise reused
import { cache } from 'react';
export const getAuthenticatedUser = cache(async () => {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
return session.user;
});
Server Actions with Optimistic Updates
Create a server action for liking posts. UI should update
instantly without waiting for the server response.
// src/app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export async function toggleLike(postId: string): Promise<{ liked: boolean; count: number }> {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const existing = await db.likes.findOne({ postId, userId: session.user.id });
if (existing) {
await db.likes.delete({ postId, userId: session.user.id });
const count = await db.likes.count({ postId });
revalidatePath('/posts');
return { liked: false, count };
} else {
await db.likes.create({ postId, userId: session.user.id });
const count = await db.likes.count({ postId });
revalidatePath('/posts');
return { liked: true, count };
}
}
// src/components/LikeButton.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleLike } from '@/app/actions/posts';
interface LikeButtonProps {
postId: string;
initialLiked: boolean;
initialCount: number;
}
export function LikeButton({ postId, initialLiked, initialCount }: LikeButtonProps) {
const [isPending, startTransition] = useTransition();
// Optimistic state — reflects what we EXPECT after the action
const [optimisticState, addOptimistic] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(_current, newState: { liked: boolean; count: number }) => newState,
);
async function handleLike() {
// Immediately update UI (optimistic)
const expectedState = {
liked: !optimisticState.liked,
count: optimisticState.count + (optimisticState.liked ? -1 : 1),
};
startTransition(async () => {
addOptimistic(expectedState);
try {
await toggleLike(postId);
// React will revalidate and show server-confirmed state
} catch {
// Optimistic update automatically reverts on error
}
});
}
return (
<button
onClick={handleLike}
disabled={isPending}
aria-pressed={optimisticState.liked}
aria-label={`${optimisticState.liked ? 'Unlike' : 'Like'} this post`}
>
{optimisticState.liked ? '❤️' : '🤍'} {optimisticState.count}
</button>
);
}
Common RSC Pitfalls
What are the most common RSC mistakes and how do I avoid them?
1. Serialization error: non-serializable props
// ❌ Cannot pass functions or class instances from server to client
<ClientComponent onClick={myServerFunction} /> // ERROR
// ✅ Use server actions or serialize the data
<ClientComponent onLike={toggleLike} /> // Server action is serializable
<ClientComponent data={product.toJSON()} /> // Serialize to plain object
2. “use client” boundary pollution
// ❌ Marking entire layouts as client destroys RSC benefits
'use client';
export default function Layout({ children }) { // Now everything is client JS
const [theme, setTheme] = useState('light');
return <div data-theme={theme}>{children}</div>;
}
// ✅ Move only the interactive part to a client component
export default function Layout({ children }) { // Server layout
return <ThemeProvider>{children}</ThemeProvider>;
}
'use client';
function ThemeProvider({ children }) { // Only this is client
const [theme, setTheme] = useState('light');
return <div data-theme={theme}>{children}</div>;
}
3. Fetching data in client components unnecessarily
// ❌ anti-pattern: client component fetches data
'use client';
function UserBadge({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`).then(r => r.json()).then(setUser);
}, [userId]);
return user ? <span>{user.name}</span> : null;
}
// ✅ Fetch in server component, pass down
async function UserBadge({ userId }: { userId: string }) {
const user = await db.users.findOne(userId); // Direct DB access, no API
return <span>{user.name}</span>;
}
For the Next.js App Router patterns that use RSC including server actions and caching, see the Next.js App Router guide. For React 19’s new features that complement RSC including the use() hook, see the React 19 guide. The Claude Skills 360 bundle includes React skill sets covering RSC patterns, composition models, and migration strategies. Start with the free tier to try React Server Components code generation.