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.
Đến giờ gần như mọi thứ chạy trên server. 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. Đó là việc của Client Components. 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. 'use client' thực sự làm gì
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>
);
}
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. 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”.
Bạn mở khóa được: useState, useEffect, useRef, useContext, useState, useEffect, useRef, useContext, event handler (onClick…), và browser API (window, localStorage). Bạn mất: khả năng await dữ liệu server trực tiếp hoặc đụng secret chỉ-server.
2. Đẩy ranh giới xuống các lá
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. 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>;
}
Cùng UI, chỉ một phần nhỏ JavaScript. Tương tác sống ở các lá; cấu trúc ở lại server.
3. Kết hợp: nội dung server bên trong vỏ client
Bạn không thể import một Server Component vào một Client Component (client không chạy được code server). 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 /> },
]}
/>
);
}
Vỏ tương tác chạy phía client; nội dung nặng ở lại render-server và ngoài bundle. Mẫu children/slot này là kỹ thuật kết hợp quan trọng nhất trong App Router.
4. Provider & context
React Context chỉ hoạt động trong Client Components. 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>
);
}
Layout vẫn là Server Component; chỉ wrapper Providers mỏng là client. Các page truyền vào children vẫn render-server.
5. URL là kho state tốt nhất
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.
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…"
/>
);
}
Khi URL đổi, page Server Component render lại trên server với searchParams mới và stream kết quả mới. Input client chỉ sửa URL; server làm việc dữ liệu.
Bọc component gọi
useSearchParamstrong<Suspense>— đọc search params đưa cây con vào render động, và Suspense cho nó một ranh giới streaming.
Giảm tần suất ghi URL
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 & bẫy không khớp
Hydration là việc React gắn listener sự kiện vào HTML đã render-server. 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>;
}
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
}
Thủ phạm thường gặp: Date.now(), Math.random(), localStorage, window, Date.now(), Math.random(), localStorage, window, và extension trình duyệt chèn markup.
7. Khi nào KHÔNG dùng Client Component
- Chỉ để fetch dữ liệu → dùng Server Component và
await. - Chỉ để đọc param → page đã nhận
params/searchParamsbất đồng bộ. - Chỉ để format/biến đổi dữ liệu hiển thị → làm trên server.
- Cho nội dung quan trọng SEO → giữ render-server.
Dùng Client Component khi bạn cần state, effect, event handler, hay browser API — không gì ít hơn.
8. Bài tập
-
Thu nhỏ bundle: 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. -
Mẫu children: 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. -
Filter trên URL: 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. -
Tìm kiếm có debounce: thêm input tìm-kiếm-trực-tiếp có debounce cập nhật URL, bọc trong
<Suspense>. -
Sửa bug hydration: render
new 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.
Phần tiếp theo
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.
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.