Phân tích
11 phút đọc2 tháng 6, 20261

React Server Components: Hiểu đúng để dùng đúng

Tìm hiểu sâu về React Server Components tại sao chúng ra đời, cách hoạt động, và khi nào nên dùng trong dự án thực tế với Next.js App Router.

N

Nguyễn Nhật Long

@nguyennhatlong1303

Trước khi có RSC, chúng ta đã build web như thế nào?

Nếu bạn đã từng code PHP, bạn sẽ nhớ cái cảm giác "gần gũi" giữa client và server. Viết một trang web, cần data gì thì query thẳng trong file .php luôn. Không có API layer, không có state management phức tạp. Mọi thứ nằm gọn trong một chỗ monolithic architecture.

Nhưng rồi web phát triển, user đòi hỏi trải nghiệm rich interactivity hơn, team scale lên, monolith trở thành bottleneck. React ra đời để giải quyết đúng bài toán đó: composability. Bạn tách UI thành từng component nhỏ, mỗi dev lo một phần, ghép lại vẫn chạy ngon vì cùng framework. Frontend và backend decouple hoàn toàn frontend trở nên cực kỳ linh hoạt.

Cái trade-off ở đây là gì? Chúng ta mất đi sự "gần gũi" giữa client và server. Data fetching giờ phải đi qua API, state phải manage ở client, bundle JavaScript ngày càng phình to. Và đó chính là bối cảnh để React Server Components (RSC) xuất hiện một nỗ lực đưa React quay lại gần server hơn, nhưng không đánh mất những gì đã đạt được suốt một thập kỷ qua.

SSR và Suspense đã giải quyết được gì (và chưa giải quyết được gì)

Trước khi nhảy vào RSC, mình muốn dừng lại ở SSR và Suspense một chút, vì nếu không hiểu hai thằng này thì sẽ rất khó thấy được tại sao RSC lại cần thiết.

SSR (Server-Side Rendering) giải quyết bài toán initial page load. Thay vì gửi một file HTML trống rỗng rồi đợi JavaScript download xong mới render, server sẽ render sẵn HTML và gửi về cho client. User thấy content nhanh hơn. Nhưng SSR có một vấn đề cốt lõi mà dân dev hay gọi là "all-or-nothing waterfall":

  1. Tất cả data phải fetch xong trên server trước khi gửi bất kỳ HTML nào về client
  2. Tất cả JavaScript phải download xong trước khi client có thể hydrate
  3. Tất cả hydration phải hoàn thành trước khi user tương tác được

Bạn thấy pattern không? Mọi thứ đều là "tất cả". Một component nặng sẽ block toàn bộ page.

React Suspense ra đời để phá vỡ cái waterfall này. Bạn wrap component nặng trong <Suspense>, server sẽ deprioritize nó, cho phép các component khác stream HTML về trước. Selective hydration cũng cho phép React ưu tiên hydrate component mà user đang cố tương tác.

JSX
1import { Suspense } from 'react';
2
3function Page() {
4 return (
5 <div>
6 <Header /> {/* Render ngay */}
7 <Suspense fallback={<Spinner />}>
8 <HeavyDashboard /> {/* Stream sau */}
9 </Suspense>
10 <Suspense fallback={<Skeleton />}>
11 <Comments /> {/* Stream sau */}
12 </Suspense>
13 </div>
14 );
15}

Ok, Suspense cải thiện rất nhiều. Nhưng vẫn còn mấy vấn đề mà nó không giải quyết được:

Và đó chính xác là những gì React Server Components sinh ra để giải quyết.

Vấn đềChi tiết
Data vẫn phải fetch toàn bộ trên serverNếu muốn fetch data ở component level, bạn phải dùng `useEffect()` ở client roundtrip dài hơn, chỉ chạy sau khi render + hydrate xong
Bundle JS vẫn phình toDù stream async, toàn bộ JavaScript cuối cùng vẫn download về client
Hydration vẫn là bottleneckUser không tương tác được cho đến khi JS download + hydrate xong cho component đó
Compute nặng đổ hết lên clientClient device có thể là điện thoại cũ, trong khi server thì mạnh và predictable hơn nhiều

React Server Components thực sự là gì?

Mình thấy nhiều anh em nhầm lẫn RSC với SSR. Hai thằng này khác nhau hoàn toàn.

SSR là một kỹ thuật render: server render HTML, gửi về client, client hydrate lại bằng JavaScript. Component vẫn chạy ở cả hai phía.

RSC là một kiểu component mới: component chỉ chạy trên server, không bao giờ được gửi JavaScript về client. Zero JS bundle cho component đó.

Điều này có nghĩa gì trong thực tế? Nghĩa là bạn có thể:

  • Import một thư viện markdown parser 50KB, dùng trong Server Component, và client không phải download 50KB đó
  • Query database trực tiếp trong component, không cần API route
  • Đọc file system, gọi internal service, làm bất cứ gì mà server làm được
JSX
1// app/posts/[slug]/page.tsx đây là Server Component (mặc định trong Next.js App Router)
2import { db } from '@/lib/database';
3import { marked } from 'marked'; // 50KB lib, không gửi về client
4
5export default async function PostPage({ params }) {
6 const post = await db.post.findUnique({
7 where: { slug: params.slug },
8 });
9
10 const htmlContent = marked(post.content);
11
12 return (
13 <article>
14 <h1>{post.title}</h1>
15 <div dangerouslySetInnerHTML={{ __html: htmlContent }} />
16 </article>
17 );
18}

Bạn thấy không? Không có useEffect, không có useState, không có loading state. Component này là một async functionawait data trực tiếp. Đây là thứ mà trước đây React không cho phép ở component level.

Theo kinh nghiệm của mình, cái "aha moment" lớn nhất khi làm việc với RSC là nhận ra rằng phần lớn UI trong một ứng dụng thực tế không cần interactivity. Một blog post, một product listing, một dashboard hiển thị data tất cả đều là static content mà không cần JavaScript ở client. RSC cho phép bạn giữ những phần đó hoàn toàn trên server.

Server Component vs Client Component: Ranh giới ở đâu?

Trong Next.js App Router, mọi component mặc định là Server Component. Bạn chỉ "opt-in" Client Component khi cần bằng directive 'use client'.

Đây là bảng so sánh nhanh:

Anh em lưu ý cái dòng "Import Server Component từ Client Component" đây là chỗ hay gặp bug nhất. Bạn không thể import một Server Component vào trong Client Component. Nhưng bạn có thể pass nó qua children prop:

Tính năngServer ComponentClient Component
Chạy ở đâuChỉ serverServer (SSR) + Client (hydration)
Gửi JS về clientKhông
Dùng `useState`, `useEffect`Không
Dùng browser API (localStorage, window)Không
Fetch data trực tiếp (async/await)Không (phải dùng useEffect hoặc lib)
Import Server ComponentKhông trực tiếp (phải pass qua children)
Import Client Component
Access backend resource (DB, file system)Không
JSX
1// ❌ SAI không hoạt động
2'use client';
3import ServerComponent from './ServerComponent';
4
5export default function ClientWrapper() {
6 return (
7 <div>
8 <ServerComponent /> {/* Sẽ bị convert thành Client Component */}
9 </div>
10 );
11}
JSX
1// ✅ ĐÚNG pass qua children
2'use client';
3
4export default function ClientWrapper({ children }) {
5 const [isOpen, setIsOpen] = useState(true);
6 return (
7 <div>
8 <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
9 {isOpen && children}
10 </div>
11 );
12}
JSX
1// Ở parent (Server Component):
2import ClientWrapper from './ClientWrapper';
3import ServerComponent from './ServerComponent';
4
5export default function Page() {
6 return (
7 <ClientWrapper>
8 <ServerComponent /> {/* Vẫn là Server Component! */}
9 </ClientWrapper>
10 );
11}

Pattern này cực kỳ quan trọng. Mình đã thấy nhiều dự án biến toàn bộ app thành Client Component chỉ vì không biết trick này. Khi bạn đặt 'use client' ở một file, tất cả component import trong file đó đều trở thành Client Component. Nó tạo ra một "client boundary". Hiểu được boundary này là chìa khóa để tận dụng RSC hiệu quả.

Data fetching trong RSC: Đơn giản đến bất ngờ

Một trong những thứ mình thích nhất ở RSC là cách fetch data. Không còn getServerSideProps, không còn useEffect + loading state boilerplate. Bạn await thẳng trong component:

JSX
1// app/dashboard/page.tsx
2import { getUser } from '@/lib/auth';
3import { db } from '@/lib/database';
4
5export default async function DashboardPage() {
6 const user = await getUser();
7 const orders = await db.order.findMany({
8 where: { userId: user.id },
9 orderBy: { createdAt: 'desc' },
10 take: 10,
11 });
12
13 return (
14 <div>
15 <h1>Chào {user.name}</h1>
16 <OrderList orders={orders} />
17 </div>
18 );
19}

Nhưng khoan nếu bạn có nhiều data cần fetch, bạn sẽ muốn parallel fetch thay vì sequential:

JSX
1export default async function DashboardPage() {
2 const user = await getUser();
3
4 // ❌ Sequential chậm
5 // const orders = await getOrders(user.id);
6 // const notifications = await getNotifications(user.id);
7
8 // ✅ Parallel nhanh hơn nhiều
9 const [orders, notifications] = await Promise.all([
10 getOrders(user.id),
11 getNotifications(user.id),
12 ]);
13
14 return (
15 <div>
16 <OrderList orders={orders} />
17 <NotificationPanel notifications={notifications} />
18 </div>
19 );
20}

Hoặc tốt hơn nữa, kết hợp với Suspense để stream từng phần:

JSX
1import { Suspense } from 'react';
2
3export default async function DashboardPage() {
4 const user = await getUser(); // Cần ngay để render layout
5
6 return (
7 <div>
8 <h1>Chào {user.name}</h1>
9 <Suspense fallback={<OrderSkeleton />}>
10 <OrderList userId={user.id} />
11 </Suspense>
12 <Suspense fallback={<NotificationSkeleton />}>
13 <NotificationPanel userId={user.id} />
14 </Suspense>
15 </div>
16 );
17}
18
19// Component này tự fetch data của nó
20async function OrderList({ userId }) {
21 const orders = await getOrders(userId); // Fetch ngay trên server
22 return (
23 <ul>
24 {orders.map(order => (
25 <li key={order.id}>{order.name} - {order.total}</li>
26 ))}
27 </ul>
28 );
29}

Pattern này cực kỳ powerful. Mỗi component tự chịu trách nhiệm fetch data của mình, Suspense boundary cho phép stream independent, và tất cả đều chạy trên server không có client-side waterfall.

Khi nào dùng Client Component?

Mình thấy cái này hay ở chỗ: RSC không phải là "thay thế" Client Component. Nó bổ sung. Rule of thumb đơn giản:

Dùng Client Component khi bạn cần:

  • Event handlers (onClick, onChange, onSubmit)
  • State (useState, useReducer)
  • Effects (useEffect, useLayoutEffect)
  • Browser-only APIs (localStorage, window, navigator)
  • Custom hooks dựa trên state hoặc effects
  • React class components

Giữ Server Component cho mọi thứ còn lại.

Trong thực tế, một page điển hình sẽ trông thế này:

TEXT
1Page (Server)
2├── Header (Server) static, không cần JS
3├── SearchBar (Client) cần onChange, state
4├── ProductList (Server) fetch data từ DB
5│ ├── ProductCard (Server) render static
6│ │ └── AddToCartButton (Client) cần onClick
7├── Sidebar (Server)
8│ └── FilterPanel (Client) cần state, interaction
9└── Footer (Server) static

Bạn thấy không? Phần lớn tree là Server Component. Chỉ những "lá" cần interactivity mới là Client Component. Đẩy 'use client' boundary xuống càng sâu càng tốt đó là best practice.

Mấy cái gotcha mà mình đã đau đầu

Sau hơn một năm làm việc với RSC trong production, mình muốn chia sẻ mấy cái trap phổ biến:

1. Serialization boundary: Khi pass props từ Server Component sang Client Component, props đó phải serializable. Nghĩa là không pass được function, Date object (phải convert sang string), Map, Set, etc.

JSX
1// ❌ Không hoạt động
2<ClientComponent onAction={() => console.log('hi')} />
3<ClientComponent date={new Date()} />
4
5// ✅ Hoạt động
6<ClientComponent dateString={date.toISOString()} />

2. Cẩn thận với third-party components: Nhiều thư viện UI chưa support RSC. Khi import component từ thư viện mà nó dùng useState bên trong, bạn sẽ gặp lỗi. Giải pháp: tạo một wrapper file với 'use client' rồi re-export.

JSX
1// components/ui/date-picker-client.tsx
2'use client';
3export { DatePicker } from 'some-ui-library';

3. Context không dùng được trong Server Component. Nếu bạn cần share state giữa nhiều Client Component, wrap chúng trong một Client Component provider, rồi pass Server Component content qua children.

4. Không cache mặc định trong dev mode. Nếu bạn thấy fetch chạy lại liên tục trong development, đó là behavior bình thường. Production sẽ khác. Nhưng bạn vẫn nên explicit về caching strategy:

JSX
1// Cache mãi mãi (static)
2fetch('https://api.example.com/data', { cache: 'force-cache' });
3
4// Không cache (dynamic)
5fetch('https://api.example.com/data', { cache: 'no-store' });
6
7// Revalidate sau 60 giây
8fetch('https://api.example.com/data', { next: { revalidate: 60 } });

RSC trong Next.js App Router: Trải nghiệm thực tế

Next.js App Router được build từ đầu xung quanh RSC. Mọi file trong app/ directory mặc định là Server Component. Điều này tạo ra một mental model rất clean:

  • page.tsx Server Component, entry point cho route
  • layout.tsx Server Component, shared layout
  • loading.tsx Tự động wrap trong Suspense boundary
  • error.tsx Phải là Client Component (cần useEffect để log error)

Một setup điển hình mà mình hay dùng trong dự án:

TEXT
1app/
2├── layout.tsx # Root layout (Server)
3├── page.tsx # Home page (Server)
4├── providers.tsx # 'use client' Theme, Auth providers
5├── dashboard/
6│ ├── page.tsx # Dashboard (Server) fetch data
7│ ├── loading.tsx # Skeleton UI
8│ ├── error.tsx # Error boundary ('use client')
9│ └── _components/
10│ ├── Stats.tsx # Server fetch + render
11│ ├── Chart.tsx # 'use client' cần D3/Chart.js
12│ └── ExportButton.tsx # 'use client' cần onClick

Root layout sẽ trông như thế này:

JSX
1// app/layout.tsx
2import { Providers } from './providers';
3
4export default function RootLayout({ children }) {
5 return (
6 <html lang="vi">
7 <body>
8 <Providers> {/* Client boundary cho theme, auth */}
9 {children} {/* Server Components vẫn là Server Components */}
10 </Providers>
11 </body>
12 </html>
13 );
14}
JSX
1// app/providers.tsx
2'use client';
3import { ThemeProvider } from 'next-themes';
4import { SessionProvider } from 'next-auth/react';
5
6export function Providers({ children }) {
7 return (
8 <SessionProvider>
9 <ThemeProvider attribute="class">
10 {children}
11 </ThemeProvider>
12 </SessionProvider>
13 );
14}

Pattern children ở đây đảm bảo rằng dù Providers là Client Component, các page bên trong vẫn có thể là Server Component.

Có nên migrate ngay không?

Thật lòng mà nói, nếu bạn đang có một dự án Next.js Pages Router chạy ổn định, không cần vội migrate. RSC và App Router vẫn đang mature, ecosystem đang bắt kịp. Nhưng nếu bạn bắt đầu dự án mới, mình strongly recommend dùng App Router + RSC từ đầu.

Lý do đơn giản: performance mặc định tốt hơn (ít JS gửi về client), data fetching pattern clean hơn, và bạn đang đi đúng hướng mà React team đang push. Cái learning curve ban đầu hơi dốc đặc biệt là hiểu ranh giới Server/Client nhưng một khi quen rồi thì quay lại cách cũ sẽ thấy thiếu.

Điều quan trọng nhất mình học được: đừng cố biến mọi thứ thành Server Component. Hãy để boundary tự nhiên. Interactive UI thì dùng Client Component, static content + data fetching thì dùng Server Component. Đơn giản vậy thôi.

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è!