Next.js Client Components — Interactivity, Hooks, and When to Use Them
Advertisement
Introduction
Client Components in Next.js are React components that run in the browser. They are marked with the 'use client' directive at the top of the file. Unlike Server Components, Client Components can use React hooks, respond to user events, and access browser APIs like localStorage, geolocation, and window. The key discipline is knowing when you actually need one.
Why This Matters
A common mistake in Next.js is wrapping large sections of an app in 'use client' unnecessarily, undoing the performance benefits of the App Router. Because Client Components ship JavaScript to the browser, overusing them increases bundle size and slows page load.
The correct mental model: every component is a Server Component until it needs something that only exists in a browser — state, effects, event handlers, or browser APIs. Move 'use client' as far down the component tree as possible. A page with a single interactive button should not make the entire page a Client Component.
Understanding Client vs Server boundaries is the most important skill for building efficient Next.js applications in 2025.
The use client Directive
The 'use client' directive marks the boundary between Server and Client Component trees. It must be the first line of the file (before imports):
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
)
}All components imported by a 'use client' file are also treated as Client Components, even if they do not have the directive themselves.
When to Use Client Components
Use 'use client' when your component needs:
- React state:
useState,useReducer - Side effects:
useEffect,useLayoutEffect - Browser APIs:
window,localStorage,navigator,document - Event handlers:
onClick,onChange,onSubmit - Real-time data: WebSockets,
EventSource - Third-party libraries that depend on browser globals
Handling Forms with State
'use client'
import { useState } from 'react'
export function ContactForm() {
const [form, setForm] = useState({ name: '', email: '', message: '' })
const [status, setStatus] = useState<'idle' | 'sending' | 'sent'>('idle')
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('sending')
await fetch('/api/contact', { method: 'POST', body: JSON.stringify(form) })
setStatus('sent')
}
if (status === 'sent') return <p>Message sent!</p>
return (
<form onSubmit={handleSubmit}>
<input name="name" value={form.name} onChange={handleChange} placeholder="Name" required />
<input name="email" type="email" value={form.email} onChange={handleChange} placeholder="Email" required />
<textarea name="message" value={form.message} onChange={handleChange} placeholder="Message" required />
<button type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending...' : 'Send'}
</button>
</form>
)
}Accessing Browser APIs
'use client'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
useEffect(() => {
// Read from localStorage on mount
const saved = localStorage.getItem('theme') as 'light' | 'dark' | null
if (saved) setTheme(saved)
}, [])
function toggle() {
const next = theme === 'light' ? 'dark' : 'light'
setTheme(next)
localStorage.setItem('theme', next)
document.documentElement.classList.toggle('dark', next === 'dark')
}
return (
<button onClick={toggle} aria-label="Toggle theme">
{theme === 'light' ? '🌙 Dark' : '☀️ Light'}
</button>
)
}Using useRouter and usePathname
Next.js navigation hooks are Client-only:
'use client'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
export function SearchBar() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams.toString())
if (term) {
params.set('q', term)
} else {
params.delete('q')
}
router.replace(`${pathname}?${params.toString()}`)
}
return (
<input
defaultValue={searchParams.get('q') ?? ''}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
/>
)
}Context Providers
Context must be created in a Client Component, but can be a thin wrapper around Server Component children:
// components/providers/CartProvider.tsx
'use client'
import { createContext, useContext, useState, ReactNode } from 'react'
type CartItem = { id: string; quantity: number }
type CartContextType = { items: CartItem[]; addItem: (id: string) => void }
const CartContext = createContext<CartContextType | null>(null)
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([])
function addItem(id: string) {
setItems((prev) => {
const existing = prev.find((i) => i.id === id)
if (existing) return prev.map((i) => i.id === id ? { ...i, quantity: i.quantity + 1 } : i)
return [...prev, { id, quantity: 1 }]
})
}
return <CartContext.Provider value={{ items, addItem }}>{children}</CartContext.Provider>
}
export function useCart() {
const ctx = useContext(CartContext)
if (!ctx) throw new Error('useCart must be inside CartProvider')
return ctx
}// app/layout.tsx — Server Component wrapping a Client Provider
import { CartProvider } from '@/components/providers/CartProvider'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<CartProvider>{children}</CartProvider>
</body>
</html>
)
}Performance: Push Client Boundaries Down
Wrong — entire page becomes a Client Component:
// app/page.tsx — BAD
'use client' // Everything below ships to browser
export default function HomePage() {
const [isOpen, setIsOpen] = useState(false)
// ... renders 1000 lines of static content
}Correct — only the interactive piece is a Client Component:
// app/page.tsx — GOOD (Server Component)
import { MobileMenu } from '@/components/MobileMenu' // Client Component
export default function HomePage() {
return (
<div>
<MobileMenu /> {/* Only this ships extra JS */}
<main>{/* Static server-rendered content */}</main>
</div>
)
}Common Mistakes
- Putting
'use client'in layouts or pages when only a small child needs it - Forgetting that
'use client'propagates — all imports from that file become client-side - Using
useEffectto fetch initial data when a Server Component could do it without JavaScript - Not handling hydration mismatches when reading
localStorageorwindowon mount - Importing large third-party libraries in Client Components without code-splitting them
Best Practices
- Default to Server Components; add
'use client'only when a browser API or hook is needed - Keep Client Components small and focused — one responsibility each
- Use
Suspenseboundaries around Client Components that load asynchronously - Wrap third-party providers in a single
ProvidersClient Component in the root layout - Use
useSearchParamswith aSuspenseboundary — Next.js requires this for static rendering
Key Takeaways
- Client Components require
'use client'as the first line of the file, before imports - They support all React hooks:
useState,useEffect,useCallback,useMemo, etc. - Browser APIs (
window,localStorage,navigator) are only available in Client Components 'use client'propagates — all components imported by a client file are also client-side- Push
'use client'as far down the tree as possible to minimize JavaScript bundle size - Context providers must be Client Components but can wrap Server Component children
- Navigation hooks (
useRouter,usePathname,useSearchParams) require'use client' - Server Components can import Client Components, but Client Components cannot import Server Components
Advertisement