Next.js 16 from Zero to Senior · Part 5 — Client Components & URL State
Use the browser deliberately: when to add "use client", the server-to-client boundary and bundle cost, composition patterns, hydration, and using the URL as state with useRouter, usePathname, and useSearchParams.
So far almost everything has run on the server {Đến giờ gần như mọi thứ chạy trên server}. But real apps need interactivity: dropdowns, tabs, forms, optimistic UI, things that respond to clicks and keystrokes {Nhưng app thật cần tương tác: dropdown, tab, form, UI lạc quan, những thứ phản hồi click và gõ phím}. That’s what Client Components are for {Đó là việc của Client Components}. The senior skill here isn’t using 'use client' — it’s using it as little as possible, at the right boundary {Kỹ năng senior ở đây không phải dùng 'use client' — mà là dùng càng ít càng tốt, ở đúng ranh giới}.
1. What 'use client' really does {'use client' thực sự làm gì}
Adding 'use client' at the top of a file marks it — and everything it imports — as part of the client bundle {Thêm 'use client' ở đầu file đánh dấu nó — và mọi thứ nó import — thuộc bundle client}:
'use client';
import { useState } from 'react';
export function Tabs({ labels }: { labels: string[] }) {
const [active, setActive] = useState(0);
return (
<div role="tablist">
{labels.map((label, i) => (
<button key={i} aria-selected={i === active} onClick={() => setActive(i)}>
{label}
</button>
))}
</div>
);
}
A Client Component still renders on the server first (for the initial HTML), then hydrates in the browser to become interactive {Một Client Component vẫn render trên server trước (cho HTML ban đầu), rồi hydrate trong trình duyệt để trở nên tương tác}. So “Client Component” doesn’t mean “client-only” — it means “this code is also shipped to and run in the browser” {Nên “Client Component” không có nghĩa “chỉ client” — nó nghĩa là “code này cũng được gửi tới và chạy trong trình duyệt”}.
What you unlock {Bạn mở khóa được}: useState, useEffect, useRef, useContext, event handlers (onClick…), and browser APIs (window, localStorage) {useState, useEffect, useRef, useContext, event handler (onClick…), và browser API (window, localStorage)}. What you lose {Bạn mất}: the ability to await server data directly or touch server-only secrets {khả năng await dữ liệu server trực tiếp hoặc đụng secret chỉ-server}.
2. Push the boundary down to the leaves {Đẩy ranh giới xuống các lá}
The classic mistake is slapping 'use client' on a big page because one button needs an onClick {Lỗi kinh điển là dán 'use client' lên cả một page lớn chỉ vì một nút cần onClick}. That ships the entire subtree to the browser {Làm vậy gửi cả cây con về trình duyệt}.
// ❌ Whole page becomes client just for a like button
'use client';
export default function Article({ post }) {
const [likes, setLikes] = useState(post.likes);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} /> {/* heavy, now client */}
<button onClick={() => setLikes(likes + 1)}>♥ {likes}</button>
</article>
);
}
// ✅ Keep the article on the server; isolate the interactive bit
export default function Article({ post }) { // Server Component
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
<LikeButton initial={post.likes} /> {/* small client leaf */}
</article>
);
}
// like-button.tsx
'use client';
import { useState } from 'react';
export function LikeButton({ initial }: { initial: number }) {
const [likes, setLikes] = useState(initial);
return <button onClick={() => setLikes(likes + 1)}>♥ {likes}</button>;
}
Same UI, a fraction of the JavaScript {Cùng UI, chỉ một phần nhỏ JavaScript}. Interactivity lives at the leaves; structure stays on the server {Tương tác sống ở các lá; cấu trúc ở lại server}.
3. Composition: server content inside client shells {Kết hợp: nội dung server bên trong vỏ client}
You cannot import a Server Component into a Client Component (the client can’t run server code) {Bạn không thể import một Server Component vào một Client Component (client không chạy được code server)}. But you can pass a Server Component through as children or any prop {Nhưng bạn có thể truyền một Server Component qua children hoặc prop bất kỳ}:
// tabs.tsx (client shell)
'use client';
import { useState } from 'react';
export function Tabs({ tabs }: { tabs: { label: string; content: React.ReactNode }[] }) {
const [i, setI] = useState(0);
return (
<>
<div role="tablist">
{tabs.map((t, idx) => (
<button key={idx} onClick={() => setI(idx)}>{t.label}</button>
))}
</div>
<div>{tabs[i].content}</div>
</>
);
}
// page.tsx (server) — ServerStats renders on the server, nested in a client shell
export default function Page() {
return (
<Tabs
tabs={[
{ label: 'Overview', content: <ServerStats /> },
{ label: 'Docs', content: <ServerDocs /> },
]}
/>
);
}
The interactive shell is client-side; the heavy content stays server-rendered and out of the bundle {Vỏ tương tác chạy phía client; nội dung nặng ở lại render-server và ngoài bundle}. This children/slot pattern is the most important composition technique in the App Router {Mẫu children/slot này là kỹ thuật kết hợp quan trọng nhất trong App Router}.
4. Providers & context {Provider & context}
React Context only works in Client Components {React Context chỉ hoạt động trong Client Components}. For app-wide providers (theme, query client), make a small client wrapper and place it in the root layout {Cho provider toàn app (theme, query client), tạo một wrapper client nhỏ và đặt ở root layout}:
// providers.tsx
'use client';
import { ThemeProvider } from 'some-theme-lib';
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
// app/layout.tsx (still a Server Component)
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
The layout stays a Server Component; only the thin Providers wrapper is client {Layout vẫn là Server Component; chỉ wrapper Providers mỏng là client}. The pages passed as children remain server-rendered {Các page truyền vào children vẫn render-server}.
5. The URL is the best state container {URL là kho state tốt nhất}
For state that should be shareable, bookmarkable, and survive refresh — filters, search queries, pagination, tabs — store it in the URL, not useState {Với state nên chia sẻ được, đánh dấu được, sống sót qua refresh — filter, truy vấn tìm kiếm, phân trang, tab — hãy lưu nó trong URL, không phải useState}.
Three hooks from next/navigation power this {Ba hook từ next/navigation lo việc này}:
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
export function SearchBox() {
const router = useRouter();
const pathname = usePathname(); // e.g. "/products"
const searchParams = useSearchParams(); // read ?q=...
function onSearch(q: string) {
const params = new URLSearchParams(searchParams);
if (q) params.set('q', q);
else params.delete('q');
router.replace(`${pathname}?${params.toString()}`); // update the URL
}
return (
<input
defaultValue={searchParams.get('q') ?? ''}
onChange={(e) => onSearch(e.target.value)}
placeholder="Search…"
/>
);
}
When the URL changes, the Server Component page re-renders on the server with the new searchParams and streams fresh results {Khi URL đổi, page Server Component render lại trên server với searchParams mới và stream kết quả mới}. Your client input just edits the URL; the server does the data work {Input client chỉ sửa URL; server làm việc dữ liệu}.
Wrap components that call
useSearchParamsin<Suspense>— reading search params opts a subtree into dynamic rendering, and Suspense gives it a streaming boundary {Bọc component gọiuseSearchParamstrong<Suspense>— đọc search params đưa cây con vào render động, và Suspense cho nó một ranh giới streaming}.
Debounce the URL writes {Giảm tần suất ghi URL}
For a live-search input, debounce so you don’t push a navigation on every keystroke {Với input tìm-kiếm-trực-tiếp, debounce để không đẩy điều hướng mỗi lần gõ}:
'use client';
import { useDebouncedCallback } from 'use-debounce';
// ...inside the component:
const handle = useDebouncedCallback(onSearch, 300);
6. Hydration & the mismatch trap {Hydration & bẫy không khớp}
Hydration is React attaching event listeners to the server-rendered HTML {Hydration là việc React gắn listener sự kiện vào HTML đã render-server}. It breaks if the server HTML and the first client render disagree {Nó hỏng nếu HTML server và lần render client đầu tiên không khớp}:
// ❌ Server renders one time, client renders another → hydration mismatch
'use client';
export function Clock() {
return <span>{new Date().toLocaleTimeString()}</span>;
}
Fix by rendering time-dependent or browser-only values after mount {Sửa bằng cách render giá trị phụ thuộc thời gian hoặc chỉ-trình-duyệt sau khi mount}:
'use client';
import { useEffect, useState } from 'react';
export function Clock() {
const [time, setTime] = useState<string | null>(null);
useEffect(() => setTime(new Date().toLocaleTimeString()), []);
return <span>{time ?? '—'}</span>; // stable on the server, fills after mount
}
Common culprits {Thủ phạm thường gặp}: Date.now(), Math.random(), localStorage, window, and browser extensions injecting markup {Date.now(), Math.random(), localStorage, window, và extension trình duyệt chèn markup}.
7. When NOT to use a Client Component {Khi nào KHÔNG dùng Client Component}
- Just to fetch data → use a Server Component and
await{Chỉ để fetch dữ liệu → dùng Server Component vàawait}. - Just to read params → pages already receive async
params/searchParams{Chỉ để đọc param → page đã nhậnparams/searchParamsbất đồng bộ}. - Just to format/transform data for display → do it on the server {Chỉ để format/biến đổi dữ liệu hiển thị → làm trên server}.
- For SEO-critical content → keep it server-rendered {Cho nội dung quan trọng SEO → giữ render-server}.
Use a Client Component when you need state, effects, event handlers, or browser APIs — nothing less {Dùng Client Component khi bạn cần state, effect, event handler, hay browser API — không gì ít hơn}.
8. Exercises {Bài tập}
-
Shrink the bundle {Thu nhỏ bundle}: take a page that’s entirely
'use client'and refactor it so only a small interactive leaf is client. Compare the JS transferred in the Network tab before/after {lấy một page toàn'use client'và refactor để chỉ một lá tương tác nhỏ là client. So sánh JS chuyển trong tab Network trước/sau}. -
Children composition {Mẫu children}: build a
'use client'<Tabs>that takescontent: React.ReactNodeper tab, and pass Server Components in. Confirm the server content isn’t in the client bundle {dựng<Tabs>'use client'nhậncontentmỗi tab, truyền Server Component vào. Xác nhận nội dung server không nằm trong bundle client}. -
URL filters {Filter trên URL}: build a product filter that writes
?category=and?sort=to the URL withuseRouter().replace, and a Server Component page that readssearchParamsto filter. Refresh and confirm state persists {dựng filter sản phẩm ghi?category=và?sort=vào URL bằnguseRouter().replace, và một page Server Component đọcsearchParamsđể lọc. Refresh và xác nhận state còn}. -
Debounced search {Tìm kiếm có debounce}: add a debounced live-search input that updates the URL, wrapped in
<Suspense>{thêm input tìm-kiếm-trực-tiếp có debounce cập nhật URL, bọc trong<Suspense>}. -
Fix a hydration bug {Sửa bug hydration}: render
new Date()directly in a Client Component, observe the hydration warning, then fix it with theuseEffectpattern {rendernew Date()trực tiếp trong một Client Component, quan sát cảnh báo hydration, rồi sửa bằng mẫuuseEffect}.
What’s next {Phần tiếp theo}
You can now add interactivity surgically, compose server content inside client shells, and use the URL as durable, shareable state {Giờ bạn thêm tương tác một cách phẫu thuật, lồng nội dung server trong vỏ client, và dùng URL làm state bền vững, chia sẻ được}.
Part 6 covers mutations: Server Actions and forms — 'use server', useActionState, useFormStatus, useOptimistic, validation with zod, and revalidating data after a write {Phần 6 nói về mutation: Server Actions và form — 'use server', useActionState, useFormStatus, useOptimistic, validate bằng zod, và revalidate dữ liệu sau khi ghi}.