Client Components
Add interactivity with "use client" -- hooks, event handlers, and browser APIs.
Recipe
Quick-reference recipe card -- copy-paste ready.
// app/components/counter.tsx
"use client";
import { useState } from "react";
export function Counter({ initialCount = 0 }: { initialCount?: number }) {
const [count, setCount] = useState(initialCount);
return (
<button onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}// app/page.tsx (Server Component imports the Client Component)
import { Counter } from "./components/counter";
export default function Page() {
return (
<main>
<h1>Welcome</h1>
<Counter initialCount={5} />
</main>
);
}When to reach for this: You need useState, useEffect, useRef, event handlers (onClick, onChange), or browser APIs (window, localStorage, IntersectionObserver).
Working Example
// app/components/search-autocomplete.tsx
"use client";
import { useState, useEffect, useRef, useTransition } from "react";
import { useRouter } from "next/navigation";
type Suggestion = { id: string; label: string };
export function SearchAutocomplete() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
useEffect(() => {
if (query.length < 2) {
setSuggestions([]);
return;
}
const controller = new AbortController();
fetch(`/api/suggestions?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then((res) => res.json())
.then((data) => setSuggestions(data))
.catch(() => {}); // ignore abort errors
return () => controller.abort();
}, [query]);
function handleSelect(suggestion: Suggestion) {
setQuery(suggestion.label);
setIsOpen(false);
startTransition(() => {
router.push(`/search?q=${encodeURIComponent(suggestion.label)}`);
});
}
return (
<div className="relative w-full max-w-md">
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
placeholder="Search..."
className="w-full border rounded px-4 py-2"
/>
{isOpen && suggestions.length > 0 && (
<ul className="absolute top-full left-0 right-0 bg-white border rounded-b shadow-lg z-10">
{suggestions.map((s) => (
<li key={s.id}>
<button
onClick={() => handleSelect(s)}
className="w-full text-left px-4 py-2 hover:bg-gray-100"
>
{s.label}
</button>
</li>
))}
</ul>
)}
{isPending && (
<span className="absolute right-3 top-2.5 text-sm text-gray-400">
Loading...
</span>
)}
</div>
);
}// app/search/page.tsx (Server Component)
import { SearchAutocomplete } from "@/app/components/search-autocomplete";
export default function SearchPage() {
return (
<main className="p-6">
<h1 className="text-2xl font-bold mb-4">Search</h1>
<SearchAutocomplete />
</main>
);
}What this demonstrates:
- The
"use client"directive at the top of the file - Multiple hooks:
useState,useEffect,useRef,useTransition - Event handlers:
onChange,onFocus,onClick - Fetching data on the client with cleanup via
AbortController - Using
useRouterfor programmatic navigation - A Server Component importing and rendering the Client Component
Deep Dive
How It Works
- Adding
"use client"at the top of a file marks it as a client boundary. The component and all its imports are included in the client JavaScript bundle. - Client Components are still server-rendered on the initial page load (SSR). The HTML is generated on the server, sent to the browser, and then hydrated -- React attaches event listeners and makes the component interactive.
- During client-side navigation, Client Components render entirely on the client using the RSC payload from the server.
- The
"use client"directive applies to the file, not a single component. All exports from that file become Client Components. - Client Components can import other Client Components (with or without
"use client"in the child). They cannot import Server Components directly. - Props passed from a Server Component to a Client Component must be serializable.
Variations
Controlled form input:
"use client";
import { useState } from "react";
export function EmailForm({ onSubmitAction }: { onSubmitAction: (email: string) => Promise<void> }) {
const [email, setEmail] = useState("");
return (
<form action={async () => { await onSubmitAction(email); }}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button type="submit">Subscribe</button>
</form>
);
}Using browser APIs safely:
"use client";
import { useEffect, useState } from "react";
export function WindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return <p>Window: {size.width} x {size.height}</p>;
}Wrapping a third-party client library:
// app/components/map.tsx
"use client";
import { MapContainer, TileLayer, Marker } from "react-leaflet";
export function Map({ lat, lng }: { lat: number; lng: number }) {
return (
<MapContainer center={[lat, lng]} zoom={13}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<Marker position={[lat, lng]} />
</MapContainer>
);
}TypeScript Notes
// Props from server must be serializable
type ClientProps = {
initialData: string[]; // OK
count: number; // OK
serverAction: (id: string) => Promise<void>; // OK (Server Action)
// onClick: () => void; // NOT OK (regular function)
// ref: React.Ref<T>; // NOT OK (not serializable)
};
// Event handler types
function handleClick(e: React.MouseEvent<HTMLButtonElement>) { ... }
function handleChange(e: React.ChangeEvent<HTMLInputElement>) { ... }
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { ... }Gotchas
-
"use client"does not mean "client-only" -- Client Components are still server-rendered (SSR) on the initial request. They run on both server and client. Fix: If you need truly client-only rendering, usedynamic(() => import("..."), { ssr: false }). -
Hydration mismatches -- If the server-rendered HTML differs from the client render (e.g., using
Date.now(),Math.random(), orwindowchecks), React logs a hydration error. Fix: UseuseEffectfor values that differ between server and client, orsuppressHydrationWarningfor intentional mismatches. -
Importing a Server Component into a Client Component -- This is not allowed. The import will be treated as a Client Component. Fix: Pass the Server Component as
childrenor another JSX prop instead. -
Bundle size creep -- Everything imported into a
"use client"file ends up in the client bundle, including utility libraries. Fix: Keep"use client"files small and focused. Import heavy libraries only where needed. -
All exports become client components -- If you export both a utility function and a component from a
"use client"file, the utility also becomes client-only. Fix: Keep utilities in separate, non-client files. -
Hooks cannot be conditional -- React hooks must be called in the same order every render. Fix: Never put hooks inside
ifblocks, loops, or early returns.
Alternatives
| Approach | Use When | Don't Use When |
|---|---|---|
| Client Components | Interactive UI with state, effects, or browser APIs | Pure data display with no interactivity |
| Server Components | Read-only rendering, data fetching, no JS shipped | You need hooks or event handlers |
dynamic(import, { ssr: false }) | Component must never render on the server (e.g., canvas libs) | SSR is fine and you just need hydration |
| Web Components | You need framework-agnostic interactive elements | React components work for your use case |
Server Actions (form action) | You can handle the interaction with a form submission | You need real-time client-side feedback |
FAQs
What does the "use client" directive actually do?
- It marks the file as a client boundary -- the component and all its imports are included in the client JavaScript bundle.
- It applies to the entire file, not a single component.
- Client Components are still server-rendered (SSR) for the initial HTML, then hydrated on the client.
Are Client Components rendered only in the browser?
No. Client Components are server-rendered on the initial page load (SSR). The server sends HTML, then React hydrates it on the client to attach event listeners and make it interactive.
When should I use a Client Component instead of a Server Component?
Use a Client Component when you need:
- React hooks (
useState,useEffect,useRef,useTransition) - Event handlers (
onClick,onChange,onFocus) - Browser APIs (
window,localStorage,IntersectionObserver)
How do I safely access browser APIs like window or localStorage?
Wrap browser API usage in useEffect so it only runs on the client after hydration:
"use client";
import { useEffect, useState } from "react";
export function WindowSize() {
const [width, setWidth] = useState(0);
useEffect(() => {
setWidth(window.innerWidth);
}, []);
return <p>Width: {width}</p>;
}Can a Client Component import a Server Component?
No. Importing a Server Component inside a "use client" file silently converts it into a Client Component. Instead, pass the Server Component as children or another JSX prop from a Server Component parent.
What props can be passed from a Server Component to a Client Component?
- Props must be serializable: strings, numbers, booleans, arrays, plain objects, and Server Actions.
- Regular functions, class instances, Refs, and Symbols are not serializable and will fail.
Gotcha: Why does adding "use client" to a file increase my bundle size significantly?
Because "use client" pulls all imports in that file into the client bundle, including utility libraries. Fix this by keeping "use client" files small and focused -- extract heavy logic into separate non-client files.
Gotcha: I see a hydration mismatch error -- what causes it?
Hydration errors occur when server-rendered HTML differs from the client render. Common causes:
- Using
Date.now()orMath.random()during render - Reading
windowproperties during render instead of inuseEffect - Fix: Move differing values into
useEffect, or usesuppressHydrationWarningfor intentional mismatches.
How do I make a component render only on the client with no SSR?
Use next/dynamic with ssr: false:
import dynamic from "next/dynamic";
const ClientOnlyMap = dynamic(
() => import("./map"),
{ ssr: false }
);How do I type event handlers in a Client Component with TypeScript?
function handleClick(e: React.MouseEvent<HTMLButtonElement>) { }
function handleChange(e: React.ChangeEvent<HTMLInputElement>) { }
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { }How do I type props for a Client Component that receives a Server Action?
Server Actions are typed as async functions returning a Promise:
type FormProps = {
onSubmitAction: (email: string) => Promise<void>;
};Regular functions (non-Server Actions) are not valid as serializable props.
What is the AbortController pattern shown in the search autocomplete example?
- The
AbortControllercancels in-flight fetch requests when theuseEffectcleanup runs (e.g., whenquerychanges). - This prevents stale responses from overwriting newer results.
- The
.catch(() => {})ignores the abort error.
What does useTransition do in a Client Component?
useTransitionlets you mark a state update as non-urgent so it does not block the UI.isPendingistruewhile the transition is running, which is useful for showing loading indicators.- In the example, it wraps
router.push()to avoid blocking the input while navigating.
Related
- Server Components -- The default rendering mode
- Composition Patterns -- Mixing server and client components
- Search Params --
useSearchParamsin Client Components - Server Actions -- Passing actions as props to Client Components