jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 useSearchParams trong <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/searchParams bấ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

  1. 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.

  2. Mẫu children: dựng <Tabs> 'use client' nhận content mỗi tab, truyền Server Component vào. Xác nhận nội dung server không nằm trong bundle client.

  3. Filter trên URL: dựng filter sản phẩm ghi ?category=?sort= vào URL bằng useRouter().replace, và một page Server Component đọc searchParams để lọc. Refresh và xác nhận state còn.

  4. 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>.

  5. 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ẫu useEffect.


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.