React Server Components: what breaks when client logic sneaks in unnoticed
When we adopt React Server Components in a project that already has code running in production, the most common mistake never shows up on the first screen we migrate. It shows up two or three components deeper in the tree, when something that worked fine under the old React model stops behaving as expected, and the error message doesn't always point at the real cause. RSC isn't an optimization layered on top of existing code; it changes what it means for a component to run at all, and that distinction has to hold in every file of the tree, not just the one currently being edited.
The "use client" boundary is a contract, not an annotation
The 'use client' directive is usually explained as a way to tell React "this component needs the browser." That description is correct but incomplete, and the missing part is what causes most of the errors: the directive doesn't mark a component, it marks a boundary in the import tree. Everything that file imports, directly or indirectly, crosses to the client side along with it.
The typical mistake during a migration is assuming it's enough to add 'use client' to the component that uses a hook, without checking what that component drags upward with it:
// components/ProductFilter.tsx
'use client'
import { useState } from 'react'
import { AnalyticsProvider } from '../lib/analytics-context'
export function ProductFilter() {
const [category, setCategory] = useState('all')
return (
<AnalyticsProvider>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All</option>
<option value="deals">Deals</option>
</select>
</AnalyticsProvider>
)
}
The component compiles and works, but AnalyticsProvider ends up bundled for the client even though its internal logic only depends on data that was already available on the server. The question we learned to ask before every 'use client' isn't "does this component need a hook?" but "what's the minimum that actually needs to run in the browser, and can I isolate just that?" The answer is almost always a smaller component than it first looked like: the <select> with its onChange, not the context provider wrapping it.
How the boundary creeps upward without anyone deciding it should
The hardest problem to diagnose isn't a mismarked component, it's silent propagation. If a server component imports a client component and renders it directly as a child, that client component stays a client component, which is correct. The problem shows up when, in order to pass it a function or a computed value that needs server-side logic, someone moves that logic inside the client component itself because "it's already there and it's faster." Every time that happens, a piece of work that could have been resolved on the server — a query, a calculation, a data transformation — now runs in every user's browser instead, with no compiler error pointing it out, because from React's point of view there's no misplaced boundary at all: there's just server logic living in the wrong place.
In one project we migrated a product list with filters to Server Components. The list rendered on the server, correctly. The filter needed interactivity, so we marked it as client, also correctly. The problem was that the function deciding which products to show based on the filter — a function that only needed the data already loaded and no browser API at all — was written inside the client component instead of being passed down as an already-computed result from the server whenever the filter changed. The result worked, but every filter change repeated in the browser work the server had already done once for the initial load, and the only way to notice it was reading the code with the right question in mind, since there was no visible symptom pointing directly at the cause.
Duplicated state: when the server and the client count two different versions of the same truth
The second recurring mistake is about state, not boundaries. When part of the tree is server and part is client, it's tempting to keep a local copy of the server's data in client state so the interface feels reactive without waiting for a new server render:
'use client'
import { useState } from 'react'
export function OrderCard({ order }: { order: Order }) {
const [localStatus, setLocalStatus] = useState(order.status)
async function markDelivered() {
setLocalStatus('delivered')
await updateOrder(order.id, 'delivered')
}
return <button onClick={markDelivered}>{localStatus}</button>
}
This pattern looks reasonable and works in many cases, but it introduces a parallel source of truth: if the server update fails, or another user changes the same order, localStatus drifts away from the real value, and since React never reads order.status again once localStatus exists, the interface keeps showing data the server no longer backs. The fix isn't avoiding local state — interactivity needs it — it's treating it as an explicit optimistic value, reversible if the update fails, rather than a permanent copy of server data. When the operation fails, we read the real value back instead of leaving the optimistic one as final.
What stays a server component, and what doesn't
The question we use to decide, component by component, is whether it needs something that only exists in the browser: state that changes from user interaction, effects, React context, or DOM APIs. If the answer is no, the component stays on the server, even if today it lives mixed in with others that are interactive. Data fetching, data formatting, calculations that don't depend on the user's browser session: all of that is a server candidate by default, and it only moves to the client when something specific forces it there.
What didn't work: putting "use client" at the top to move faster
During the migration, our first response to every hydration error or hook-outside-context error was to push the 'use client' directive one level higher in the tree, up to the whole section's layout. The errors disappeared, the migration moved faster, and that speed was exactly the signal that we were doing the opposite of what we wanted: with the directive on the layout, the entire subtree became client again, including the data loading that had lived comfortably on the server before we touched anything. We reverted that change component by component, pushing 'use client' toward the leaves of the tree instead of toward the root, which is slower but is the only path that preserves the actual reason for adopting Server Components in the first place.
Before calling a migration done
We check three things in every migrated section: that each 'use client' sits on the smallest possible component rather than a container that drags in unnecessary dependencies, that no client state is a silent copy of server data without explicit handling for when the update fails, and that logic with no real need for the browser — calculations, transformations, data access — still lives on the server side. When all three hold, the mixed tree stops being a source of intermittent bugs and starts behaving the way it was meant to from the start: server by default, client only where interaction actually requires it.