Client Components, State, and Browser APIs

Client Components are the part of a Next.js App Router application that can hold React state, respond to browser events, run effects, and call browser-only APIs such as localStorage, window, document, navigator, and ResizeObserver. The outcome of this lesson is practical: you should know when to add "use client", where to place it, what it changes in the module graph, and how to avoid turning a mostly server-rendered route into a large client bundle.

This topic sits inside the React Server and Client Design section because the App Router defaults to Server Components. That default is valuable: server-only code can read databases, environment variables, and files without shipping its implementation to the browser. Client Components are the explicit escape hatch for interactivity. Good Next.js design is not server-only or client-only; it is a small set of client islands embedded inside server-rendered structure.

The Boundary Mechanism

A file becomes a Client Component entry point when its first statements include the directive "use client". That directive is not a runtime function call. It is a compilation signal. Next.js and React treat that module, plus the modules it imports for rendering, as client-side code. The browser receives JavaScript for that component tree, React hydrates it, and then hooks and event handlers can run.

The boundary is contagious downward through imports. If app/products/page.tsx is a Server Component and it imports FilterPanel from a file marked "use client", only FilterPanel and its client import graph need to hydrate. If page.tsx itself is marked "use client", the entire page module becomes part of the client graph and loses direct access to server-only capabilities. This is the most important design choice: put the directive as low as possible, at the smallest interactive component boundary.

Server Components render into a React Server Component payload that describes UI, server-rendered output, and references to client component modules. Props crossing from server to client must be serializable by React. Plain strings, numbers, booleans, arrays, and objects work. Functions, class instances, database clients, file handles, and most rich runtime objects do not. Event handlers are therefore defined inside Client Components, not passed from a Server Component into a Client Component as ordinary props.

API Anatomy

The main syntax is short, but its placement matters:

  • "use client" must appear at the top of the file before imports that execute application logic.
  • Client Components may use useState, useReducer, useEffect, useRef, event handlers, and browser APIs.
  • Server Components may import Client Components, but Client Components should not import server-only modules such as database clients or filesystem utilities.
  • Values passed from Server Components to Client Components must be serializable props.
  • Browser APIs should be read during events or effects, not during server rendering.

A useful mental model is: Server Components prepare data and stable markup; Client Components own interaction after hydration. Initial UI should not depend on a browser-only value unless you provide a deterministic placeholder and update it after mount.

Example 1: A Small Interactive Island

The first example keeps a route as a Server Component and adds a small client counter. The page can still fetch or read server data, while the button hydrates as an isolated client island.

// app/workshop/page.tsx
import Counter from "./Counter";

export default async function WorkshopPage() {
  const title = "Client islands";

  return (
    <main>
      <h1>{title}</h1>
      <p>The heading is rendered on the server.</p>
      <Counter initialValue={2} />
    </main>
  );
}

// app/workshop/Counter.tsx
"use client";

import { useState } from "react";

type CounterProps = {
  initialValue: number;
};

export default function Counter({ initialValue }: CounterProps) {
  const [count, setCount] = useState(initialValue);

  return (
    <button type="button" onClick={() => setCount((value) => value + 1)}>
      Count: {count}
    </button>
  );
}

Expected behavior: the first HTML response contains the page structure and a button that says Count: 2. After hydration, clicking the button changes the label to Count: 3, then Count: 4. The server page does not need to become a Client Component because only the counter needs state and an event handler.

Example 2: Reading Browser Storage Safely

localStorage exists only in the browser. Reading it during render can fail on the server or create a hydration mismatch if the server renders one value and the browser immediately renders another. Read it after mount and render a stable initial value first.

"use client";

import { useEffect, useState } from "react";

export default function ThemeChoice() {
  const [theme, setTheme] = useState("system");

  useEffect(() => {
    const saved = window.localStorage.getItem("theme");
    if (saved === "light" || saved === "dark" || saved === "system") {
      setTheme(saved);
    }
  }, []);

  function chooseTheme(nextTheme: string) {
    setTheme(nextTheme);
    window.localStorage.setItem("theme", nextTheme);
  }

  return (
    <fieldset>
      <legend>Theme</legend>
      {["light", "dark", "system"].map((option) => (
        <button
          key={option}
          type="button"
          aria-pressed={theme === option}
          onClick={() => chooseTheme(option)}
        >
          {option}
        </button>
      ))}
    </fieldset>
  );
}

Expected behavior: the component initially renders system as selected, matching the server output. After the effect runs, a saved value such as dark becomes selected. Clicking light updates React state immediately and persists theme=light in browser storage. The important design detail is that the first render is deterministic.

Example 3: Subscribing to Browser Events

Some browser APIs are subscriptions rather than one-time reads. A resize listener must be installed after mount and removed during cleanup, otherwise navigation between routes can leave duplicate listeners behind.

"use client";

import { useEffect, useState } from "react";

function readViewportWidth() {
  return window.innerWidth;
}

export default function ViewportBadge() {
  const [width, setWidth] = useState<number | null>(null);

  useEffect(() => {
    function handleResize() {
      setWidth(readViewportWidth());
    }

    handleResize();
    window.addEventListener("resize", handleResize);

    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  if (width === null) {
    return <p>Measuring viewport...</p>;
  }

  return <p>Viewport width: {width}px</p>;
}

Expected behavior: the server and initial browser render show Measuring viewport.... After mount, the component displays the current width. Resizing the browser updates the number. Leaving the route removes the listener. This pattern applies to matchMedia, keyboard listeners, visibility changes, and observers.

Design Choices and Trade-offs

The smallest Client Component is usually best, but not always. Splitting every button into a separate file can make code harder to understand. A practical boundary contains one interaction concern: a filter panel, menu, chart controls, form wizard, or media player. Keep static layout, database reads, authorization checks, and heavy formatting on the server unless they directly need browser state.

Passing more props across the boundary can reduce client fetching, but it increases payload size. Fetching again from the client can keep props small, but it adds latency and requires an API route or server action design. For initial page data, prefer server preparation and serializable props. For rapidly changing browser-local state, keep it in the Client Component. For shared URL state, use search params so the state can be linked, refreshed, and rendered consistently.

Client Components also affect performance. Every client entry point and its imported rendering dependencies can add JavaScript to the browser bundle. Avoid importing large utility libraries, markdown processors, server SDKs, or whole design systems into a tiny interactive component when a smaller helper would do. Use the bundle analyzer or build output when a route feels heavier than expected.

Failure Modes and Troubleshooting

Symptom: the build fails with window is not defined. Cause: a browser API ran while rendering on the server or while evaluating an imported module. Diagnose: search for window, document, and localStorage outside effects and event handlers. Correct: move the read into a Client Component effect, or guard it behind a function that runs only in the browser.

Symptom: React reports that hydration failed because server HTML does not match the client. Cause: the first browser render used a value that the server could not know, such as current time, viewport width, random output, or storage. Diagnose: compare the server-rendered fallback with the first client render. Correct: render a stable placeholder and update after mount, or provide the value through cookies, headers, or search params when it must be known on the server.

Symptom: a route unexpectedly ships a large JavaScript bundle. Cause: "use client" was placed too high or the client component imports heavy modules. Diagnose: inspect which files are imported by the client entry point. Correct: move the directive down, pass plain data from the server, and replace heavy client imports with smaller browser-safe helpers.

Symptom: a Client Component tries to receive an onClick function from a Server Component and fails serialization. Cause: functions cannot cross the Server Component to Client Component prop boundary as ordinary values. Diagnose: inspect props passed into the client entry. Correct: define event handlers in the Client Component, or use a server action where appropriate for a form or mutation.

Security, Reliability, and Performance

Never move secrets or privileged operations into a Client Component. Environment variables intended only for the server, database clients, admin SDKs, and authorization decisions belong on the server. A Client Component can improve the interface, but the browser is controlled by the user. Treat client state as convenience, not authority.

For reliability, clean up subscriptions and abort long-running browser work when components unmount. For performance, keep initial client state small, avoid unnecessary effects, and do not mirror server data into client state unless the user can actually edit or interact with it. A derived label can be computed during render; it does not need useState.

Hands-on Lab

Prerequisites: a Next.js App Router project, Node.js installed, and a route where you can add a small component. The lab creates a browser-only preference panel while keeping the route itself server-rendered.

  1. Create app/preferences/page.tsx as a Server Component that renders a heading and imports PreferencePanel.
  2. Create app/preferences/PreferencePanel.tsx with "use client", useState, and an effect that reads localStorage.
  3. Add three buttons for compact, comfortable, and spacious density values.
  4. Start the dev server and open /preferences.
  5. Click a density option, refresh the page, and verify that the saved option is restored after hydration.
  6. Temporarily move the localStorage read into the state initializer without a browser guard, observe the failure or mismatch risk, then roll back to the effect-based version.
// app/preferences/page.tsx
import PreferencePanel from "./PreferencePanel";

export default function PreferencesPage() {
  return (
    <main>
      <h1>Preferences</h1>
      <PreferencePanel />
    </main>
  );
}

// app/preferences/PreferencePanel.tsx
"use client";

import { useEffect, useState } from "react";

const densities = ["compact", "comfortable", "spacious"] as const;
type Density = (typeof densities)[number];

function isDensity(value: string | null): value is Density {
  return value === "compact" || value === "comfortable" || value === "spacious";
}

export default function PreferencePanel() {
  const [density, setDensity] = useState<Density>("comfortable");

  useEffect(() => {
    const saved = window.localStorage.getItem("density");
    if (isDensity(saved)) {
      setDensity(saved);
    }
  }, []);

  function chooseDensity(nextDensity: Density) {
    setDensity(nextDensity);
    window.localStorage.setItem("density", nextDensity);
  }

  return (
    <section aria-label="Display density">
      {densities.map((option) => (
        <button
          key={option}
          type="button"
          aria-pressed={density === option}
          onClick={() => chooseDensity(option)}
        >
          {option}
        </button>
      ))}
      <p>Current density: {density}</p>
    </section>
  );
}

Verification: the route loads without server errors, the selected button updates immediately, a refresh restores the saved value after mount, and the page file itself does not contain "use client". Cleanup: remove the two lab files or clear the stored value with localStorage.removeItem("density") in the browser console.

Assessment Exercises

  1. A dashboard page fetches account data on the server and has one collapsible filter drawer. Where should "use client" go, and what props should cross the boundary?
  2. Rewrite a component that reads window.innerWidth during render so that it avoids server errors and hydration mismatch.
  3. Given a Client Component that imports a database helper, explain why it is unsafe and describe the server/client split you would use instead.
  4. A theme selector needs to work on first paint without flicker. Compare using localStorage, cookies, and search params for that requirement.
  5. Inspect a route that became slow after adding a menu. List two ways the client boundary could have increased shipped JavaScript.

Summary

Client Components are the browser-capable part of a Next.js App Router tree. Use them for state, effects, events, and browser APIs, but place the boundary narrowly. Keep server data work on the server, pass only serializable props, read browser-only values after mount or through server-visible inputs, clean up subscriptions, and verify bundle impact when adding interactivity.