Kinh nghiệm
6 phút đọc15 tháng 6, 2026366

Server vs Client Components: Đặt 'use client' đúng chỗ hay hối hận cả đời

Ranh giới giữa Server và Client Components là thứ quyết định app Next.js của bạn nhanh hay chậm, gọn hay nặng nề.

N

Nguyễn Nhật Long

@nguyennhatlong1303

Server vs Client Components: Đặt 'use client' đúng chỗ hay hối hận cả đời

Bài 7 trong series React đến Next.js, và đây là bài mình nghĩ nhiều anh em sẽ gật đầu kiểu "ừ nhỉ, mình đã làm sai cái này rồi".

Khi mình mới tiếp cận App Router, phản xạ đầu tiên là cứ thấy component nào cần useState là thêm 'use client' vào, xong thấy lỗi thì thêm tiếp vào file cha, rồi file cha nữa... Cuối cùng cả cái page đều là client component hết. Ship JS lên browser như điên mà không hiểu tại sao.

Vấn đề không phải ở 'use client' mà ở chỗ đặt nó ở đâu.

Mặc định là Server, và đó là điều tốt

Trong App Router, mọi component đều là Server Component theo mặc định. Không cần khai báo gì cả. Và điều này có nghĩa là:

  • Component chạy hoàn toàn trên server
  • Có thể await thẳng trong component body không cần useEffect để fetch
  • Có thể đọc database, đọc file system, gọi internal API
  • 0kb JavaScript gửi xuống client
TSX
1// app/posts/page.tsx Server Component, không cần khai báo gì
2export default async function PostsPage() {
3 // Fetch thẳng, không cần useEffect, không cần useState
4 const posts = await db.post.findMany();
5
6 return (
7 <ul>
8 {posts.map(post => (
9 <li key={post.id}>{post.title}</li>
10 ))}
11 </ul>
12 );
13}

Mình thấy cái này hay ở chỗ: data fetching giờ trở nên cực kỳ thẳng thắn. Không còn cái pattern useEffect + useState + loading state quen thuộc nhưng verbose nữa. Component trông gần giống một function bình thường hơn.

Nhưng đổi lại, Server Components không thể dùng bất kỳ thứ gì liên quan đến browser hay React state:

Không dùng đượcDùng được
`useState`, `useReducer``async/await` trực tiếp
`useEffect`, `useLayoutEffect`Fetch data, query DB
`onClick`, `onChange`, event handlersRead environment variables
`window`, `document`, browser APIsAccess file system
Context (nếu dùng `useContext`)Import server-only packages

'use client' không phải toggle bật/tắt nó là ranh giới

Đây là phần nhiều người hiểu nhầm nhất.

'use client' không có nghĩa là "component này chạy trên client". Nó có nghĩa là đây là điểm bắt đầu của client boundary. Mọi thứ được import vào trong file này (và các file con của nó) đều trở thành client code.

TSX
1'use client';
2
3import { useState } from 'react';
4import { HeavyLibrary } from 'some-lib'; // Cái này cũng bị ship xuống client luôn
5
6export function Counter() {
7 const [count, setCount] = useState(0);
8 return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
9}

Vì vậy, nguyên tắc vàng: đặt 'use client' càng thấp trong component tree càng tốt.

Ví dụ thực tế: bạn có một trang blog. Toàn bộ trang đó layout, content, related posts đều không cần interactivity. Chỉ có cái nút bookmark là cần onClick. Thay vì biến cả page thành client component, extract mỗi cái nút đó ra:

TSX
1// app/blog/[slug]/page.tsx Server Component
2import { BookmarkButton } from '@/components/BookmarkButton';
3
4export default async function BlogPost({ params }) {
5 const post = await getPost(params.slug);
6
7 return (
8 <article>
9 <h1>{post.title}</h1>
10 <div>{post.content}</div>
11 {/* Chỉ mỗi cái này là client */}
12 <BookmarkButton postId={post.id} />
13 </article>
14 );
15}
TSX
1// components/BookmarkButton.tsx Client Component
2'use client';
3
4import { useState } from 'react';
5
6export function BookmarkButton({ postId }: { postId: string }) {
7 const [saved, setSaved] = useState(false);
8
9 return (
10 <button onClick={() => setSaved(s => !s)}>
11 {saved ? '★ Saved' : '☆ Save'}
12 </button>
13 );
14}

Kết quả: cả trang render trên server, chỉ ship JS cho mỗi cái button nhỏ đó.

Hai pattern composition quan trọng

Pattern 1: Server wraps Client cái này straightforward, bạn fetch data trên server rồi pass xuống client component qua props:

TSX
1// Server Component fetch data, pass xuống
2export default async function Page() {
3 const user = await getUser();
4 return <ProfileForm initialData={user} />; // ProfileForm là 'use client'
5}

Lưu ý: props truyền qua boundary phải serializable tức là plain objects, strings, numbers, arrays. Không truyền được functions, class instances, hay Date objects (trừ khi convert sang string trước).

Pattern 2: Client wraps Server qua children cái này ít người biết hơn nhưng cực kỳ hữu dụng:

TSX
1// ClientLayout.tsx
2'use client';
3
4import { useState } from 'react';
5
6export function Sidebar({ children }: { children: React.ReactNode }) {
7 const [open, setOpen] = useState(true);
8
9 return (
10 <div className={open ? 'open' : 'closed'}>
11 <button onClick={() => setOpen(o => !o)}>Toggle</button>
12 {children} {/* children vẫn có thể là Server Components! */}
13 </div>
14 );
15}
TSX
1// page.tsx (Server)
2export default async function Page() {
3 const data = await fetchData();
4 return (
5 <Sidebar>
6 <ServerContent data={data} /> {/* Vẫn là server component */}
7 </Sidebar>
8 );
9}

Tại sao cái này hoạt động? Vì children được tạo ra ở server, rồi được truyền vào client component như một prop nó không bị "kéo vào" client boundary. Mình thấy đây là một trong những mental model thú vị nhất của RSC.

Component

Những lỗi mình thấy cực kỳ phổ biến

Đặt 'use client' ở page level vì lười. Đây là cái mình thấy nhiều nhất trong code review. Cả page biến thành client component, toàn bộ data fetching phải dùng useEffect, bundle size tăng vô tội vạ. Fix: chỉ extract phần interactive ra.

Dùng useEffect để fetch data trong khi đó là việc của Server Component:

TSX
1// ❌ Đừng làm thế này nếu không cần thiết
2'use client';
3export function PostList() {
4 const [posts, setPosts] = useState([]);
5 useEffect(() => {
6 fetch('/api/posts').then(r => r.json()).then(setPosts);
7 }, []);
8 // ...
9}
10
11// ✅ Làm thế này
12export default async function PostList() {
13 const posts = await getPosts();
14 // ...
15}

Import server-only code vào client component. Ví dụ import Prisma client vào một file có 'use client' Next.js sẽ báo lỗi, nhưng đôi khi lỗi không rõ ràng. Dùng package server-only để guard:

TSX
1import 'server-only'; // Throw error nếu file này bị import vào client
2
3export async function getSecretData() {
4 return db.secret.findMany();
5}

Chuyện third-party libraries

Đây là nỗi đau thực sự. Rất nhiều UI libraries phổ biến Radix UI, Headless UI, Framer Motion chưa add 'use client' directive vào package của họ (hoặc một số component chưa có). Khi bạn import vào Server Component, Next.js sẽ complain.

Workaround đơn giản nhất: tạo một wrapper file:

TSX
1// components/ui/dialog.tsx
2'use client';
3
4export { Dialog, DialogContent, DialogHeader } from '@radix-ui/react-dialog';

Rồi import từ wrapper này thay vì trực tiếp từ package. Hơi annoying nhưng chỉ cần làm một lần.

Theo kinh nghiệm của mình, cách nhanh nhất để check một package có support RSC không là tìm 'use client' trong source code của nó, hoặc check README/changelog xem họ có mention "React Server Components" không.

Tóm lại thì nghĩ thế nào cho đúng?

Mình hay dùng mental model này: mặc định mọi thứ là server, chỉ "opt in" vào client khi thực sự cần. Và khi cần, hãy extract ra component nhỏ nhất có thể.

Nếu bạn thấy mình đang viết useEffect để fetch data dừng lại và hỏi: cái này có thể là Server Component không? 90% trường hợp là có.

Nếu bạn thấy 'use client' ở đầu một file page lớn đó là signal để refactor, extract phần interactive ra component riêng.

Bài tiếp theo mình sẽ đi vào data fetching patterns sâu hơn fetch với caching, revalidate, và khi nào nên dùng Route Handlers thay vì fetch thẳng trong Server Component. Anh em theo dõi nhé.

NN

Nguyễn Nhật Long

@nguyennhatlong1303

Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.

Thấy hay? Chia sẻ cho bạn bè!

Server vs Client Components: Đặt 'use client' đúng chỗ hay hối hận cả đời — Stacklog