React Server Components — Part III of III
Making RSC Work — Patterns, Pitfalls, and the Honest Answer
Okay. You've got the theory. Now comes the part where theory meets the messiness of real applications.
In this part I want to talk about the patterns that actually work, the mistakes I see people make, and — honestly — when RSC is not the right call. Because that's the thing nobody wants to say: RSC is not always the answer.
Test yourself first
Before we get into patterns, let's make sure the boundary rules are solid. Six code snippets, you guess whether they're server or client components. Some are obvious, some are edge cases.
async function UserProfile({ userId }: { userId: string }) {
const user = await db.users.findById(userId);
return (
<div>
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
);
}The patterns that actually work
1. Colocate data with the component that needs it
This is the big one. In the old model, data fetching lived at the top of your component tree (or in a state management layer), and you threaded it down via props or context. With RSC, each server component can fetch exactly what it needs, right where it needs it.
// page.tsx
export async function getServerSideProps({ params }) {
const [user, posts, comments] = await Promise.all([
fetchUser(params.id),
fetchPosts(params.id),
fetchComments(params.id),
]);
return { props: { user, posts, comments } };
}
function Page({ user, posts, comments }) {
return (
<>
<UserCard user={user} />
<PostList posts={posts} />
<Comments comments={comments} />
</>
);
}// page.tsx (server component — no data fetching here)
import { Suspense } from 'react';
export default function Page({ params }) {
return (
<>
<Suspense fallback={<UserCardSkeleton />}>
<UserCard userId={params.id} />
</Suspense>
<Suspense fallback={<PostListSkeleton />}>
<PostList userId={params.id} />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments userId={params.id} />
</Suspense>
</>
);
}
// UserCard.tsx (server component)
async function UserCard({ userId }) {
const user = await db.users.findById(userId);
return <div>{user.name}</div>;
}
// PostList.tsx (server component)
async function PostList({ userId }) {
const posts = await db.posts.findMany({ where: { userId } });
return <ul>{posts.map(p => <PostRow key={p.id} post={p} />)}</ul>;
}Now the page component doesn't care about data at all. Each component is responsible for its own data. And because they're wrapped in Suspense, they stream in independently — UserCard doesn't wait for Posts to be ready.
2. Keep "use client" boundaries at the leaves
The most common mistake I see: slapping "use client" on a layout component or a wrapper because one small piece of it needs to be interactive. That converts the entire subtree to client-rendered code.
// ✗ Don't do this — the whole sidebar becomes client JS
'use client';
export function Sidebar({ user }) {
return (
<aside>
<UserAvatar user={user} /> {/* static, no client needed */}
<NavigationLinks /> {/* static */}
<ThemeToggle /> {/* this is the only thing that needs client */}
</aside>
);
}// ✓ Sidebar stays as a server component
// Only ThemeToggle is a client component
// Sidebar.tsx (server component)
export function Sidebar({ user }) {
return (
<aside>
<UserAvatar user={user} /> {/* server rendered */}
<NavigationLinks /> {/* server rendered */}
<ThemeToggle /> {/* client component, isolated */}
</aside>
);
}
// ThemeToggle.tsx
'use client';
export function ThemeToggle() {
const [dark, setDark] = useState(false);
return <button onClick={() => setDark(d => !d)}>Toggle</button>;
}3. You can't pass functions as props across the boundary
Server components can only pass serializable values to client components. That means: strings, numbers, booleans, arrays, plain objects. Not functions, not class instances, not anything that can't be JSON-serialized.
// ✗ Server components can't pass functions to client components
async function ServerComponent() {
const handleAction = () => console.log('action'); // can't serialize this
return <ClientButton onClick={handleAction} />; // ✗ Error
}
// ✓ Define the handler in the client component instead
// ServerComponent.tsx
async function ServerComponent({ id }) {
return <ClientButton itemId={id} />; // ✓ pass the data, not the function
}
// ClientButton.tsx
'use client';
function ClientButton({ itemId }) {
const handleClick = () => console.log('clicked', itemId); // handler lives here
return <button onClick={handleClick}>Click</button>;
}The patterns that cause problems
Third-party library compatibility
This is still a genuine pain point in 2024. A lot of popular React libraries were written assuming a browser environment. They call window, they useuseEffect on import, they read document in module scope. None of that works in a server component.
The workaround is usually to wrap the library usage in a client component, which means you're back to the old behavior for that slice of your tree. It's not ideal, but it's often the pragmatic call.
// ChartWrapper.tsx
'use client';
// SomeChartLibrary assumes window exists — wrap it in a client component
import { SomeChartLibrary } from 'some-chart-library';
export function ChartWrapper({ data }) {
return <SomeChartLibrary data={data} />;
}
// page.tsx (server component)
async function ReportPage() {
const chartData = await db.metrics.getMonthly();
return (
<div>
<h1>Monthly Report</h1>
<ChartWrapper data={chartData} /> {/* ← client component, gets serialized data */}
</div>
);
}Context across the boundary
React Context doesn't work the same way across the server/client boundary. Context consumers inside server components won't pick up values provided from client-side context providers, because by the time the server runs, there's no client-side context yet.
The practical solution: keep context providers in client components (they usually are anyway), and pass data to server components through props or by reading from the server-side equivalent (cookies, headers, database).
When NOT to use RSC
This is the section most blog posts skip. Let me be honest about when RSC is the wrong choice.
- If you're building something like a Figma canvas, a rich text editor, a real-time collaborative tool — RSC doesn't help you. Most of your UI will be client components anyway. The overhead of thinking about the boundary might not be worth it.
- WebSockets, live updates, optimistic mutations — these are client-territory. RSC is a request/response model. It doesn't help with data that changes after the initial render.
- If your React app talks to a separate backend that your team owns and maintains, RSC may not add much. You're already solving the data fetching problem at the API layer. The main benefit of RSC — collocated server-side data access — is less compelling when you have a well-designed API.
The performance question
"Does RSC make my app faster?" is the wrong question. A better question is: "What does my app do less of with RSC?"
The answer: your browser downloads and parses less JavaScript. Components that are purely server-rendered never touch the client bundle. For content-heavy apps — blogs, documentation sites, dashboards with lots of static sections — that can be a meaningful difference.
But RSC doesn't automatically speed up your database queries. It doesn't eliminate the need for good caching strategies. It doesn't make slow network requests fast. The wins are real, but they're specific.
The biggest performance win I've seen from RSC isn't raw speed — it's the elimination of unnecessary round-trips. When your components can fetch their own data on the server instead of orchestrating multiple client-side API calls, you're removing entire network hops from the critical path.
Where does this leave us?
RSC is a genuinely good idea that has a genuinely steep learning curve. The waterfall problem was real. Shipping a ton of unnecessary JavaScript to the browser was a real cost. The solution — letting some components live entirely on the server — makes sense.
What makes it hard is that React is asking you to think about component boundaries in a new way. The client/server distinction used to be at the page level. Now it's at the component level. That's a meaningful conceptual shift, and it takes a while to feel natural.
My honest take after working with it for a while: start with the App Router, let components be server components by default, and only reach for "use client" when you actually need it. Don't fight the model — the defaults are usually right.
And when you hit something confusing, come back to the fundamentals. Server components render on the server and never ship their code to the browser. Client components are the parts that need to be interactive. The boundary is a module system concept, not a component property. Everything else follows from those three things.
That's the whole series. Hopefully it clicked in a way that the official docs haven't. If something is still fuzzy — honestly, that's normal. RSC is one of those things where you need to build something with it before it fully lands.