React Server Components (RSC) là thay đổi lớn nhất React kể từ hooks. Bài này giải thích RSC từ đầu, code Next.js App Router minh hoạ — và quan trọng nhất: khi nào dùng server, khi nào client.
Vấn đề RSC giải
App SPA truyền thống: server gửi HTML rỗng + JS bundle 500KB. Browser tải, parse, execute, fetch data, render. Time to Interactive 3-5 giây trên 3G.
SSR truyền thống: server render HTML, gửi cùng JS bundle. HTML hiện nhanh nhưng vẫn phải hydrate — JS download xong mới interactive.
RSC: render trên server thành "RSC payload" (không phải HTML thuần). Component không cần interactivity → KHÔNG gửi JS xuống client. Bundle giảm 30-70% với app điển hình.
Mặc định: server component
// app/blog/page.tsx — Server Component (default trong Next App Router)
import { db } from '@/lib/db'
export default async function BlogPage() {
const posts = await db.posts.findAll() // ← fetch trực tiếp, không cần API
return (
<div>
<h1>Blog</h1>
{posts.map(p => (
<article key={p.id}>
<h2>{p.title}</h2>
<p>{p.excerpt}</p>
</article>
))}
</div>
)
}
Component này:
- Chạy trên server, fetch DB trực tiếp
- Render thành RSC payload (binary serialized)
- Browser nhận, render — KHÔNG download JS của component
- Chỉ tốn JS cho Next.js runtime (~30KB) + client component nếu có
Khi nào "use client"?
// components/like-button.tsx
'use client'
import { useState } from 'react'
export default function LikeButton({ postId }: { postId: number }) {
const [liked, setLiked] = useState(false)
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'} {liked ? 'Liked' : 'Like'}
</button>
)
}
Cần "use client" khi component dùng:
- Hook:
useState,useEffect,useReducer,useRef - Event handler:
onClick,onChange,onSubmit - Browser API:
window,localStorage,navigator - Class component (legacy)
- React Context Provider
Composition: server import client
// app/blog/[slug]/page.tsx — Server Component
import LikeButton from '@/components/like-button' // client component
import { db } from '@/lib/db'
export default async function PostPage({ params }) {
const post = await db.posts.findBySlug(params.slug)
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton postId={post.id} /> {/* client island trong server tree */}
</article>
)
}
Pattern: phần lớn page là server (nội dung), interactive bit là client island. Bundle JS chỉ cho island — toàn bộ render content không cost client JS.
Streaming + Suspense
Server component có thể await — nhưng đừng để 1 query chậm block toàn page. Suspense + streaming:
import { Suspense } from 'react'
export default function Dashboard() {
return (
<div>
<Header /> {/* render ngay */}
<Suspense fallback={<Spinner />}>
<UserProfile /> {/* fetch chậm — fallback hiện trước */}
</Suspense>
<Suspense fallback={<Spinner />}>
<RecentOrders />
</Suspense>
</div>
)
}
async function UserProfile() {
const user = await fetch('https://slow-api...').then(r => r.json())
return <div>{user.name}</div>
}
Page render header ngay. UserProfile và RecentOrders stream xuống khi sẵn sàng. User thấy progressive load thay vì màn trắng.
Server Actions — mutation
// actions.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
await db.posts.insert({ title, content })
revalidatePath('/blog') // invalidate cache
}
// form.tsx — client component dùng action
'use client'
import { createPost } from './actions'
export default function NewPostForm() {
return (
<form action={createPost}>
<input name="title" />
<textarea name="content" />
<button>Tạo</button>
</form>
)
}
Form submit gọi function server trực tiếp — không cần API endpoint riêng. Validation, auth check làm trong action.
Data fetching pattern mới
// ❌ Cũ: client fetch qua /api/posts
'use client'
function Posts() {
const [posts, setPosts] = useState([])
useEffect(() => { fetch('/api/posts').then(r => r.json()).then(setPosts) }, [])
// ...
}
// ✓ Mới: server component fetch trực tiếp
async function Posts() {
const posts = await db.posts.findAll()
return <ul>{posts.map(p => <li>{p.title}</li>)}</ul>
}
Lợi: không có loading state, không có error handling client, SQL query có thể optimized nhờ chạy gần DB, secret không expose client.
3 pitfall thường gặp
- "use client" toàn page: mất hoàn toàn benefit RSC. Đẩy "use client" xuống component nhỏ nhất cần.
- Pass non-serializable prop: server không thể truyền function/Date object xuống client component. Phải JSON-able.
- Quên revalidate: server action mutate data nhưng cache vẫn cũ → user thấy stale.
revalidatePath()hoặcrevalidateTag().
Kết luận
React Server Components là tương lai của React framework. Mặc định server, "use client" cho island interactive. Bundle giảm, fetch nhanh, secret an toàn. Nếu đang start project mới, dùng Next.js App Router với RSC native — đừng cố stick với Pages Router. Tham khảo Migrate App Router.