Skip to content

The use client Boundary

advanced15 min read

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.

Mental Model

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.

Quiz
A file has 'use client' at the top and exports three components. How many of those components are Client Components?

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 children prop
  • 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 use API 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.

Quiz
Which of these props would cause an error when passed from a Server Component to a Client Component?

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.

Quiz
You have a large data table (100 rows, 10 columns) with a single 'Sort' button. Where should 'use client' go?

Rules of the Boundary

Key Rules
  1. 1'use client' marks a module boundary, not a component. Every export in the file becomes a Client Component.
  2. 2Push 'use client' as far down the tree as possible. The higher it is, the more JS you ship.
  3. 3Only serializable values can cross from Server to Client Components as props. Functions cannot (except Server Actions).
  4. 4Server Components can render Client Components. Client Components cannot import Server Components (but can receive them as children).
  5. 5The donut pattern: Client Components handle state/events (the shell), Server Components handle data/rendering (the filling, passed as children).
  6. 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 doWhat 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'
Interview Question

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."