The use client Boundary
It's a Module Boundary, Not a Component Marker
Here is the single most misunderstood thing about 'use client': it doesn't mark a component as a client component. It marks a module boundary — the point where the server world ends and the client world begins.
When you put 'use client' at the top of a file, you're telling the bundler: "Everything in this file and everything it imports should be included in the client bundle." Every component in that file becomes a Client Component. Every module imported from that file is pulled into the client bundle.
Think of 'use client' as a border checkpoint between two countries. On the server side, you can carry anything — database connections, file system access, secret keys. But at the checkpoint, border guards inspect everything crossing over. Only serializable items (strings, numbers, plain objects) can cross. Functions, class instances, and server resources get confiscated. Once you cross, you're in client territory — you can use useState, useEffect, and browser APIs, but you can never go back.
'use client'
import { useState } from 'react'
import { formatDate } from '@/lib/utils'
export function LikeButton({ postId, initialCount }) {
const [count, setCount] = useState(initialCount)
return (
<button onClick={() => setCount(c => c + 1)}>
{count} likes
</button>
)
}
export function ShareButton({ url }) {
return (
<button onClick={() => navigator.clipboard.writeText(url)}>
Share
</button>
)
}
Both LikeButton and ShareButton are Client Components — the directive applies to the entire module. And formatDate from @/lib/utils gets pulled into the client bundle because this module imports it.
The Serialization Boundary
When a Server Component renders a Client Component, it passes props across a serialization boundary. The props are serialized on the server, sent over the network, and deserialized on the client.
What can cross the boundary:
- Strings, numbers, booleans, null, undefined
- Arrays and plain objects (containing serializable values)
- Date, Map, Set, TypedArray
- React elements (JSX) — specifically the
childrenprop - Server Actions (functions marked with
'use server')
What cannot cross:
- Regular functions and closures
- Class instances
- Symbols (except well-known ones)
- DOM nodes
- Promises (unless using the
useAPI pattern)
async function ProductPage({ slug }) {
const product = await fetchProduct(slug)
return (
<AddToCartButton
productId={product.id}
price={product.price}
onAdd={() => {}}
/>
)
}
This breaks. onAdd={() => {}} is a function — it can't be serialized. The fix is to either move the logic into the Client Component or use a Server Action:
import { addToCart } from './actions'
async function ProductPage({ slug }) {
const product = await fetchProduct(slug)
return (
<AddToCartButton
productId={product.id}
price={product.price}
addToCart={addToCart}
/>
)
}
'use server'
export async function addToCart(productId: string) {
await db.cart.add({ productId, userId: getCurrentUser() })
}
Server Actions (functions with 'use server') can cross the boundary because they become RPC endpoints — the client gets a reference that calls back to the server.
The Placement Problem
The most common performance mistake with 'use client' is putting it too high in the component tree.
'use client'
export default function ProductPage({ product, reviews, recommendations }) {
const [selectedTab, setSelectedTab] = useState('details')
return (
<div>
<ProductHeader product={product} />
<TabBar selected={selectedTab} onSelect={setSelectedTab} />
{selectedTab === 'details' && <ProductDetails product={product} />}
{selectedTab === 'reviews' && <Reviews data={reviews} />}
{selectedTab === 'recommendations' && <Recs data={recommendations} />}
</div>
)
}
Because ProductPage is 'use client', everything it imports is pulled into the client bundle — ProductHeader, ProductDetails, Reviews, Recs, and all their dependencies. Even if those components are pure display with no interactivity.
The fix: push 'use client' as far down as possible.
import { TabContainer } from './TabContainer'
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.slug)
const reviews = await fetchReviews(params.slug)
const recs = await fetchRecommendations(params.slug)
return (
<div>
<ProductHeader product={product} />
<TabContainer
tabs={['Details', 'Reviews', 'Recommendations']}
>
<ProductDetails product={product} />
<Reviews data={reviews} />
<Recs data={recs} />
</TabContainer>
</div>
)
}
'use client'
import { useState } from 'react'
export function TabContainer({ tabs, children }) {
const [activeIndex, setActiveIndex] = useState(0)
return (
<div>
<div role="tablist">
{tabs.map((tab, i) => (
<button
key={tab}
role="tab"
aria-selected={i === activeIndex}
onClick={() => setActiveIndex(i)}
>
{tab}
</button>
))}
</div>
<div role="tabpanel">
{Array.isArray(children) ? children[activeIndex] : children}
</div>
</div>
)
}
Now only TabContainer is a Client Component (a tiny state wrapper). ProductHeader, ProductDetails, Reviews, and Recs are all Server Components — their JS never ships to the client.
The Donut Pattern
This is the most important composition pattern in RSC architecture: the donut.
The idea: Client Components form a thin interactive "shell" (the donut), and Server Components fill the center (the hole). The Client Component handles state and events; the Server Component handles data and rendering.
async function CommentSection({ postId }) {
const comments = await fetchComments(postId)
return (
<CollapsiblePanel title={`${comments.length} Comments`}>
{comments.map(comment => (
<Comment key={comment.id} {...comment} />
))}
</CollapsiblePanel>
)
}
'use client'
import { useState } from 'react'
export function CollapsiblePanel({ title, children }) {
const [isOpen, setIsOpen] = useState(false)
return (
<section>
<button onClick={() => setIsOpen(!isOpen)}>
{title} {isOpen ? '▼' : '▶'}
</button>
{isOpen && <div>{children}</div>}
</section>
)
}
CollapsiblePanel is the donut — it handles the open/close state. The comments (Server Components passed as children) are the filling. The comments are rendered on the server and their JS is never sent to the client. The Client Component only ships a few lines of toggle logic.
How children cross the boundary
When a Server Component passes JSX as children to a Client Component, the JSX is resolved on the server and serialized as part of the RSC payload. The Client Component receives pre-rendered React elements — not component functions to execute. This is why the pattern works: the Client Component never imports or executes the Server Component code. It just renders the already-resolved output.
Rules of the Boundary
- 1'use client' marks a module boundary, not a component. Every export in the file becomes a Client Component.
- 2Push 'use client' as far down the tree as possible. The higher it is, the more JS you ship.
- 3Only serializable values can cross from Server to Client Components as props. Functions cannot (except Server Actions).
- 4Server Components can render Client Components. Client Components cannot import Server Components (but can receive them as children).
- 5The donut pattern: Client Components handle state/events (the shell), Server Components handle data/rendering (the filling, passed as children).
- 6A single useState in a large component doesn't mean the whole page needs 'use client'. Extract the stateful part into its own tiny Client Component.
| What developers do | What they should do |
|---|---|
| Put 'use client' at the top of page components Marking the page as 'use client' pulls everything it imports into the client bundle — layouts, data display, utilities — all unnecessarily shipped to the browser. | Keep pages as Server Components, extract only interactive widgets into Client Components |
| Think 'use client' means 'only runs on the client' Client Components are SSR'd for initial HTML, then hydrated. The directive controls bundling and hydration scope, not where the first render happens. | 'use client' components still render on the server for SSR — they just also hydrate on the client |
| Import large data-display components into 'use client' files Any import from a 'use client' file enters the client bundle. Use composition (children/render props) to keep data-heavy components server-only. | Use the children pattern to pass Server Component output through Client Components |
| Create one giant 'use client' utility file One big 'use client' file means importing anything from it pulls everything into the client bundle. Granular files enable better tree shaking. | Split client utilities into focused files, each with their own 'use client' |
FAANG interview question: "Explain the difference between 'use client' and 'use server' directives. Can a file have both?"
Strong answer: 'use client' marks a module boundary where everything in the file and its imports become Client Components — they ship JavaScript to the browser and can use hooks/browser APIs. 'use server' marks functions as Server Actions — they create RPC endpoints that clients can call. A file cannot have both at the top level. However, a 'use client' file can import and use Server Actions from a separate 'use server' file. The directives control the bundler: 'use client' says "include in client bundle," 'use server' says "keep on server, expose as network endpoint."