Skip to content

Client Components and When to Use Them

Aman Kumar Singh8 min read
Part 17 of 50From the React & Next.js Complete Guide series
Client Components and When to Use Them — article by Aman Kumar Singh

The previous article in this series covered Server Components: what they buy you, how data fetching changes shape, and why the App Router treats them as the default. This one covers the other half of that model. Server Components are the default. Client Components are the opt-in, and knowing exactly when to opt in is the skill that separates a fast App Router app from one that quietly ships the entire component tree to the browser anyway.

This is part of the React & Next.js Complete Guide, and it assumes you already know why Server Components exist. What it adds is the practical decision: given a component that needs interactivity, how much of the tree actually has to move to the client.

A lot of App Router migrations go one of two ways. Either every component gets "use client" out of habit from the Pages Router days, which throws away most of the benefit of Server Components. Or nobody adds it where it's actually needed, and the app throws confusing errors about hooks not being supported. Both come from the same gap: no clear mental model of what the directive does and where the line should sit.

What "use client" actually marks

"use client" is a directive, not a component type in the way class or function is. You put it at the top of a file. It tells the bundler: this module, and everything that module exports, belongs to the client bundle.

"use client";

import { useState } from "react";

export function QuantityStepper({ initial = 1 }: { initial?: number }) {
  const [quantity, setQuantity] = useState(initial);

  return (
    <div className="flex items-center gap-2">
      <button onClick={() => setQuantity((q) => Math.max(1, q - 1))}>-</button>
      <span>{quantity}</span>
      <button onClick={() => setQuantity((q) => q + 1)}>+</button>
    </div>
  );
}

The name is a little misleading, and it trips people up constantly: a Client Component is not "rendered only on the client." Next.js still server-renders it as part of the initial HTML response, same as any Server Component. The difference is what happens after. A Client Component ships its JavaScript to the browser, gets hydrated, and can attach event listeners, hold state, and re-render on user interaction. A Server Component never does any of that; its output is static HTML, with no client-side JavaScript backing it.

The directive really says "this component needs a browser runtime, so include it in the client bundle and hydrate it." Everything downstream of that boundary, every component this file imports directly, gets pulled into the same bundle, whether or not those child components use any client-only features themselves. That's the part with real cost, and it's why where you draw this line matters more than whether you draw it.

CapabilityServer Component (default)Client Component ("use client")
Ships JavaScript to the browserNoYes — bundled and hydrated
Server-rendered into the initial HTMLYesYes, then hydrates
useState / useEffect / event handlersNoYes
Browser APIs (window, localStorage)NoYes
await data directly in the component bodyYesNo
React Context provider or consumerNoYes

Deciding when a component needs to be a Client Component

The honest test is narrower than most people assume. A component needs "use client" when it does at least one of these:

  • Holds local state with useState or useReducer.
  • Uses an effect (useEffect, useLayoutEffect) to synchronize with something outside React.
  • Attaches event handlers that respond to user interaction: onClick, onChange, onSubmit, and so on.
  • Reads or writes browser-only APIs: window, localStorage, IntersectionObserver, geolocation.
  • Consumes or provides React Context via useContext or a Context.Provider.
  • Depends on a custom hook that does any of the above.
  • Imports a third-party library that itself expects a browser runtime (most charting libraries, drag-and-drop libraries, and a fair number of UI kits fall into this category).

Fetching data is deliberately not on that list. It's one of the most common reasons people reach for "use client" unnecessarily, usually out of a useEffect-plus-fetch reflex from the Pages Router. In the App Router, a Server Component can await a database call directly in its body, before anything is sent to the client. That has nothing to do with interactivity, so it belongs on the server side of the boundary by default.

A concrete example: a product detail page with static product information and an "add to cart" button. The page itself doesn't need any client JavaScript. Only the button does, because it needs local state for a pending indicator and an onClick handler.

// app/products/[id]/page.tsx (Server Component, no directive needed)
import { getProduct } from "@/lib/products";
import { AddToCartButton } from "./add-to-cart-button";

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>${product.price.toFixed(2)}</p>
      <AddToCartButton productId={product.id} />
    </article>
  );
}
// app/products/[id]/add-to-cart-button.tsx
"use client";

import { useState } from "react";
import { addToCart } from "./actions";

export function AddToCartButton({ productId }: { productId: string }) {
  const [isPending, setIsPending] = useState(false);

  async function handleClick() {
    setIsPending(true);
    try {
      await addToCart(productId);
    } finally {
      setIsPending(false);
    }
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      {isPending ? "Adding..." : "Add to Cart"}
    </button>
  );
}

The page component fetches data on the server and stays out of the client bundle entirely. Only AddToCartButton, a small leaf, gets shipped as interactive JavaScript. That split is the whole exercise.

Keep the boundary at the leaves, not the root

The mistake I see most often is marking a whole page, layout, or feature module "use client" because one piece of it needs interactivity. Once a file has the directive, every component it imports goes with it, even components that would have been perfectly happy as Server Components on their own.

The fix is composition. A Client Component can still receive Server Components as children, because children is just a prop, and props can be React elements already rendered by the parent before the boundary. The Client Component doesn't re-execute that content; it renders whatever tree it was handed.

This matters most for Context providers, which have to be Client Components because useContext requires a client runtime, but which don't need to pull the rest of the app into the client bundle with them.

// app/providers/theme-provider.tsx
"use client";

import { createContext, useState, type ReactNode } from "react";

export const ThemeContext = createContext<"light" | "dark">("light");

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme] = useState<"light" | "dark">("dark");

  return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
}
// app/layout.tsx (Server Component)
import { ThemeProvider } from "./providers/theme-provider";
import { Dashboard } from "./dashboard"; // still a Server Component

export default function RootLayout() {
  return (
    <ThemeProvider>
      <Dashboard />
    </ThemeProvider>
  );
}

Dashboard is passed in as children, rendered by the layout before ThemeProvider ever sees it. ThemeProvider wraps that already-rendered tree in a context boundary without pulling Dashboard or anything underneath it into the client bundle. This pattern is worth internalizing early. It's the difference between "one component needs a provider" and "the entire page tree is now client-rendered."

What can (and can't) cross the boundary

Props passed from a Server Component into a Client Component have to be serializable, because they're transmitted as part of the React payload, not shared by reference. Strings, numbers, plain objects, arrays, and null cross the boundary fine. Functions generally don't, with one exception: Server Actions, which React and Next.js serialize as a special reference the client can call, triggering the actual execution back on the server.

// A Server Action passed as a prop is fine
import { deleteItem } from "./actions";

export default function ItemRow({ item }: { item: { id: string; name: string } }) {
  return <DeleteButton itemId={item.id} onDelete={deleteItem} />;
}

Class instances and non-Server-Action functions will either fail to serialize or lose their behavior. Needing to pass a complex object across the boundary is usually a sign the transformation belongs on the server, with only plain data crossing over.

The reverse direction has its own rule: a Client Component file cannot import a Server Component module directly. Everything a "use client" file imports is treated as client code. The only way in is through composition, passed as children or another prop by a parent that rendered it first.

Production pitfalls

Marking a layout or page "use client" for one interactive child. This is the single most common source of bundle bloat in App Router migrations. Push the directive down to the smallest component that actually needs it, and let composition carry the rest.

Fetching data inside a Client Component with useEffect instead of fetching on the server. This reintroduces the client-side fetch waterfall that Server Components were built to avoid. A blank shell ships first. Then a request fires from the browser. Then the UI updates. If the data doesn't depend on client-only state, fetch it in a Server Component and pass it down as a prop.

Importing a heavy third-party library that quietly requires "use client". Some UI and charting libraries touch window or use hooks internally, forcing any file that imports them to become a Client Component even if your own code has no interactivity. Check for a lighter server-safe variant, or isolate the import to a small wrapper component.

Trying to pass a callback that isn't a Server Action from a Server Component to a Client Component. This fails at build or serialization time. If the Client Component needs to trigger server-side logic, that logic needs to be an actual Server Action, not a plain function reference.

Treating Context as free. Every Context provider is a Client Component by definition, and while composition keeps the rest of the tree server-rendered, the provider and its own state still hydrate on the client. Several separate providers, each wrapping the tree independently, add up in initial JavaScript even when each one's state is small.

Key takeaways

  • `"use client"` marks a module boundary for the client bundle. It doesn't mean the component skips server rendering; Next.js still renders it on the server for the initial HTML.
  • Add the directive only when a component needs state, effects, event handlers, browser APIs, or a hook/library that depends on those.
  • Push the client boundary to the smallest leaf component that needs it, and use the `children` composition pattern to keep Server Components deep inside a tree even when a parent provider must be a Client Component.
  • Props crossing from a Server Component to a Client Component must be serializable. Functions are the exception only when they're Server Actions.
  • A Client Component file cannot import a Server Component directly; the only path in is through props or children handed down by a parent that already rendered it.
  • Data fetching belongs on the server side of the boundary by default. Reaching for `useEffect` plus `fetch` inside a Client Component usually reintroduces the waterfall Server Components exist to remove.

Frequently asked questions

Does a Client Component still render on the server?

Yes. Next.js server-renders Client Components as part of the initial HTML, exactly like Server Components. The distinction is that Client Components also ship JavaScript to the browser and hydrate, so they can hold state and respond to events afterward.

Can I use async/await directly inside a Client Component the way I can in a Server Component?

Not as the top-level component function body. Data fetching that requires `await` at the component level should happen in a Server Component and get passed down as props, or triggered from an effect or event handler if it genuinely depends on client-side state.

Why does adding "use client" to one small component sometimes bloat my bundle so much?

Check what that file imports. The directive pulls the entire import tree of that file into the client bundle, so if it imports a large shared module or a heavy utility library, all of that comes along, even if your own component code is small.

Can a Server Component be a child of a Client Component?

Yes, but only through composition. A parent Server Component renders the Server Component first and passes the result as `children` (or another prop) into the Client Component. The Client Component itself can never `import` a Server Component module directly.

Is it ever fine to make an entire page a Client Component?

Occasionally, for something genuinely interactive top to bottom, like a canvas-based editor or a real-time collaborative view. For a typical CRUD page with a handful of interactive controls, keeping the page as a Server Component and isolating interactivity to leaf components is almost always the better default.

How do I pass an event handler from a Server Component to a Client Component?

You generally don't, unless it's a Server Action. A plain function defined in a Server Component can't cross the boundary as a prop. If the Client Component needs server-side logic, define that logic as a Server Action and pass the action itself.

Related articles

Core Web Vitals for React Apps — Aman Kumar Singh
Image and Asset Optimization — Aman Kumar Singh
Building a Design System — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.