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.
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":
- Tất cả data phải fetch xong trên server trước khi gửi bất kỳ HTML nào về client
- Tất cả JavaScript phải download xong trước khi client có thể hydrate
- 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.
1import { Suspense } from 'react';23function 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 server | Nế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 to | Dù stream async, toàn bộ JavaScript cuối cùng vẫn download về client |
| Hydration vẫn là bottleneck | User không tương tác được cho đến khi JS download + hydrate xong cho component đó |
| Compute nặng đổ hết lên client | Client 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
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ề client45export default async function PostPage({ params }) {6 const post = await db.post.findUnique({7 where: { slug: params.slug },8 });910 const htmlContent = marked(post.content);1112 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 function nó await 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ăng | Server Component | Client Component |
|---|---|---|
| Chạy ở đâu | Chỉ server | Server (SSR) + Client (hydration) |
| Gửi JS về client | Không | Có |
| Dùng `useState`, `useEffect` | Không | Có |
| Dùng browser API (localStorage, window) | Không | Có |
| Fetch data trực tiếp (async/await) | Có | Không (phải dùng useEffect hoặc lib) |
| Import Server Component | Có | Không trực tiếp (phải pass qua children) |
| Import Client Component | Có | Có |
| Access backend resource (DB, file system) | Có | Không |
1// ❌ SAI không hoạt động2'use client';3import ServerComponent from './ServerComponent';45export default function ClientWrapper() {6 return (7 <div>8 <ServerComponent /> {/* Sẽ bị convert thành Client Component */}9 </div>10 );11}
1// ✅ ĐÚNG pass qua children2'use client';34export 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}
1// Ở parent (Server Component):2import ClientWrapper from './ClientWrapper';3import ServerComponent from './ServerComponent';45export 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:
1// app/dashboard/page.tsx2import { getUser } from '@/lib/auth';3import { db } from '@/lib/database';45export 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 });1213 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:
1export default async function DashboardPage() {2 const user = await getUser();34 // ❌ Sequential chậm5 // const orders = await getOrders(user.id);6 // const notifications = await getNotifications(user.id);78 // ✅ Parallel nhanh hơn nhiều9 const [orders, notifications] = await Promise.all([10 getOrders(user.id),11 getNotifications(user.id),12 ]);1314 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:
1import { Suspense } from 'react';23export default async function DashboardPage() {4 const user = await getUser(); // Cần ngay để render layout56 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}1819// Component này tự fetch data của nó20async function OrderList({ userId }) {21 const orders = await getOrders(userId); // Fetch ngay trên server22 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:
1Page (Server)2├── Header (Server) static, không cần JS3├── SearchBar (Client) cần onChange, state4├── ProductList (Server) fetch data từ DB5│ ├── ProductCard (Server) render static6│ │ └── AddToCartButton (Client) cần onClick7├── Sidebar (Server)8│ └── FilterPanel (Client) cần state, interaction9└── 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.
1// ❌ Không hoạt động2<ClientComponent onAction={() => console.log('hi')} />3<ClientComponent date={new Date()} />45// ✅ Hoạt động6<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.
1// components/ui/date-picker-client.tsx2'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:
1// Cache mãi mãi (static)2fetch('https://api.example.com/data', { cache: 'force-cache' });34// Không cache (dynamic)5fetch('https://api.example.com/data', { cache: 'no-store' });67// Revalidate sau 60 giây8fetch('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.tsxServer Component, entry point cho routelayout.tsxServer Component, shared layoutloading.tsxTự động wrap trong Suspense boundaryerror.tsxPhải là Client Component (cầnuseEffectđể log error)
Một setup điển hình mà mình hay dùng trong dự án:
1app/2├── layout.tsx # Root layout (Server)3├── page.tsx # Home page (Server)4├── providers.tsx # 'use client' Theme, Auth providers5├── dashboard/6│ ├── page.tsx # Dashboard (Server) fetch data7│ ├── loading.tsx # Skeleton UI8│ ├── error.tsx # Error boundary ('use client')9│ └── _components/10│ ├── Stats.tsx # Server fetch + render11│ ├── Chart.tsx # 'use client' cần D3/Chart.js12│ └── ExportButton.tsx # 'use client' cần onClick
Root layout sẽ trông như thế này:
1// app/layout.tsx2import { Providers } from './providers';34export 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}
1// app/providers.tsx2'use client';3import { ThemeProvider } from 'next-themes';4import { SessionProvider } from 'next-auth/react';56export 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.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễ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è!