✦

React Server Components — 'use client' đặt ở đâu là đúng?

•Phong Hy

Có lần mình mở Lighthouse cho một trang sản phẩm viết bằng Next.js App Router. Điểm SEO xanh lè, nhưng bundle JS gửi xuống trình duyệt hơn 480KB. Trong khi cả trang chỉ có đúng một chỗ cần tương tác: nút thả tim. Hoá ra mình đã đặt 'use client' ở quá cao, kéo nguyên cây component xuống client theo.

Hai thế giới nằm chung một cây component

Trong App Router, mọi component mặc định là Server Component. Nó chạy trên server, được phép await thẳng trong thân hàm, đọc database, đọc file, gọi nội bộ — và quan trọng nhất: không gửi một byte JS nào cho trình duyệt.

Client Component là phần còn lại, chỉ đánh dấu bằng 'use client' ở đầu file. Nó mới là thứ đi vào bundle, mới có useState, useEffect, onClick.

Nói cách khác: Server Component lo phần dữ liệu và HTML, Client Component lo phần tương tác. Ranh giới giữa hai thế giới đó là thứ quyết định bundle của bạn nặng hay nhẹ.

'use client' là ranh giới, không phải thư mục

Rất nhiều người tưởng đặt file vào thư mục components/ là thành client, còn app/ là server. Không phải. Ranh giới được vẽ tại đúng dòng 'use client', và nó lan xuống toàn bộ con của component đó.

Cách đặt đúng thường là: để page fetch dữ liệu ở server, rồi chỉ đẩy phần cần tương tác xuống client.

// app/products/[slug]/page.tsx — Server Component
import { db } from '@/lib/db'
import { LikeButton } from './like-button'

export default async function ProductPage({ params }: { params: { slug: string } }) {
  const product = await db.product.findUnique({ where: { slug: params.slug } })
  if (!product) return <p>Không tìm thấy sản phẩm.</p>

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <LikeButton productId={product.id} initialLikes={product.likes} />
    </article>
  )
}
// app/products/[slug]/like-button.tsx
'use client'

import { useState } from 'react'

export function LikeButton({
  productId,
  initialLikes,
}: {
  productId: string
  initialLikes: number
}) {
  const [likes, setLikes] = useState(initialLikes)
  const [pending, setPending] = useState(false)

  async function like() {
    setPending(true)
    setLikes((l) => l + 1)
    await fetch(`/api/products/${productId}/like`, { method: 'POST' })
    setPending(false)
  }

  return (
    <button onClick={like} disabled={pending}>
      ❤️ {likes}
    </button>
  )
}

Chỉ nút này là client. Truy vấn DB, render markdown, format giá — hết ở server. Bundle giảm từ 480KB xuống 92KB gzip, đúng sau hai lần refactor.

Bẫy hay gặp nhất: kéo cả layout xuống client

Đây là lỗi mình thấy nhiều nhất khi review code Next.js. Sidebar chỉ cần state mở/đóng, thế là cả file layout bị gắn 'use client'. Kết quả: layout đó không còn await được, và mọi page con bên trong cũng thành client sạch.

Cách chữa là truyền children như một prop. Component server render children trước, rồi mới gửi kết quả đã render qua ranh giới.

// app/dashboard/layout.tsx — Server Component
import { Sidebar } from './sidebar' // client, cần state
import { getCurrentUser } from '@/lib/auth'

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const user = await getCurrentUser() // chạy ở server, không lộ token

  return (
    <div className="flex">
      <Sidebar user={user} />
      <main>{children}</main>
    </div>
  )
}

children không bị kéo xuống client. Chỉ Sidebar đi, phần nặng ở lại server.

Props phải đi qua được đường dây JSON

Ranh giới RSC thực chất là một ranh giới serialize. Props đi từ server sang client phải là dữ liệu thô: string, number, boolean, array, plain object. Function, class instance, Symbol, Map, Set đều không qua được.

// ❌ Function không serialize được qua ranh giới
<LikeButton onLike={() => console.log('liked')} />

// ✅ Truyền dữ liệu thô, hành vi để client tự lo
<LikeButton productId={product.id} createdAt={product.createdAt.toISOString()} />

Lỗi này hay hiện ra dưới dạng "Functions cannot be passed directly to Client Components" — đọc là hiểu ngay, nhưng nếu bạn đang truyền cả object entity từ Prisma thì nên map lại thành DTO trước. Vừa nhẹ payload, vừa không vô tình đẩy field nội bộ xuống trình duyệt.

Đừng để code server lọt ra client

Một dòng này ở đầu file kết nối database là đủ để bạn ngủ ngon:

// lib/db.ts
import 'server-only'

export const db = createClient(process.env.DATABASE_URL!)

Nếu file này lỡ bị import từ một component có 'use client', build gãy ngay tại chỗ đó. Gãy lúc build luôn tốt hơn lộ connection string lúc chạy.

Vài con số sau khi dọn

  • Bundle chính: 480KB → 92KB gzip
  • Time to Interactive trên 4G chậm: 3.4s → 1.6s
  • Số file client: 37 → 9
  • Nhánh 'use client' bị kéo lên layout: 4 chỗ

Ranh giới client/server không phải chuyện thẩm mỹ cho vui — nó là chuyện chi phí thật, đo được.

Bạn đang đặt 'use client' ở đâu trong dự án của mình? Nếu nó đang nằm ở layout hoặc page, thử kéo xuống sát nút bấm xem bundle đổi thế nào. Nhiều khi chỉ cần một lần di chuyển là xong.

✦