Đi đến nội dung
·32 phút đọc

React Server Components: Mô Hình Tư Duy, Pattern và Các Bẫy Cần Tránh

Hướng dẫn thực tế về React Server Components mà tôi ước đã tồn tại khi tôi bắt đầu. Mô hình tư duy, pattern thực tế, vấn đề ranh giới, và những sai lầm tôi mắc phải để bạn không phải lặp lại.

Chia sẻ:X / TwitterLinkedIn

Tôi đã dành ba tháng đầu tiên với React Server Components trong niềm tin rằng mình hiểu chúng. Tôi đọc RFC, xem các bài nói chuyện tại hội nghị, xây dựng vài app demo. Tôi tự tin một cách sai lầm về gần như mọi thứ.

Vấn đề không phải RSC phức tạp. Vấn đề là mô hình tư duy thực sự khác biệt so với bất cứ thứ gì chúng ta từng làm trong React trước đây, và tất cả mọi người — bao gồm cả tôi — đều cố nhét nó vào cái hộp cũ. "Nó giống SSR." Không phải. "Nó giống PHP." Gần hơn, nhưng cũng không. "Nó chỉ là component chạy trên server." Đúng về mặt kỹ thuật, nhưng vô dụng trong thực tế.

Dưới đây là tất cả những gì tôi thực sự cần biết, viết theo cách mà tôi ước ai đó đã giải thích cho tôi. Không phải phiên bản lý thuyết. Mà là phiên bản khi bạn đang chằm chằm vào lỗi serialization lúc 11 giờ đêm và cần hiểu tại sao.

Mô Hình Tư Duy Thực Sự Hiệu Quả#

Hãy tạm quên mọi thứ bạn biết về cách React render. Đây là bức tranh mới.

Trong React truyền thống (phía client), toàn bộ cây component của bạn được gửi tới trình duyệt dưới dạng JavaScript. Trình duyệt tải về, parse, thực thi, rồi render kết quả. Mỗi component — dù là form tương tác 200 dòng hay một đoạn văn bản tĩnh — đều đi qua cùng một pipeline.

React Server Components chia tách thành hai thế giới:

Server Components chạy trên server. Chúng thực thi một lần, tạo ra output, và gửi kết quả tới client — không phải code. Trình duyệt không bao giờ thấy hàm component, không bao giờ tải dependencies của nó, không bao giờ re-render nó.

Client Components hoạt động như React truyền thống. Chúng được gửi tới trình duyệt, hydrate, duy trì state, xử lý event. Đó là React mà bạn đã biết.

Insight quan trọng mà tôi mất một thời gian dài đáng xấu hổ mới thấm: Server Components là mặc định. Trong Next.js App Router, mọi component đều là Server Component trừ khi bạn chủ động opt-in vào client bằng "use client". Điều này ngược lại với những gì chúng ta quen thuộc, và nó thay đổi cách bạn nghĩ về composition.

Rendering Waterfall#

Đây là những gì thực sự xảy ra khi người dùng request một trang:

1. Request đến server
2. Server thực thi Server Components từ trên xuống
3. Khi Server Component gặp ranh giới "use client",
   nó dừng lại — subtree đó sẽ render trên client
4. Server Components tạo ra RSC Payload (định dạng đặc biệt)
5. RSC Payload được stream tới client
6. Client render Client Components, ghép chúng vào
   cây đã được server render
7. Hydration làm cho Client Components tương tác được

Bước 4 là nơi phần lớn sự nhầm lẫn nằm. RSC Payload không phải HTML. Nó là một định dạng streaming đặc biệt mô tả cây component — server đã render những gì, client cần tiếp quản ở đâu, và props nào cần truyền qua ranh giới.

Nó trông đại khái như thế này (đã đơn giản hoá):

M1:{"id":"./src/components/Counter.tsx","chunks":["272:static/chunks/272.js"],"name":"Counter"}
S0:"$Sreact.suspense"
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome"}],["$","$L1",null,{"initialCount":0}]]}]

Bạn không cần ghi nhớ định dạng này. Nhưng hiểu rằng nó tồn tại — rằng có một lớp serialization giữa server và client — sẽ tiết kiệm cho bạn hàng giờ debug. Mỗi khi bạn gặp lỗi "Props must be serializable", đó là vì thứ gì đó bạn đang truyền không thể sống sót qua quá trình chuyển đổi này.

"Chạy Trên Server" Thực Sự Có Nghĩa Gì#

Khi tôi nói Server Component "chạy trên server", tôi có ý nghĩa đen. Hàm component thực thi trong Node.js (hoặc Edge runtime). Điều này có nghĩa bạn có thể:

tsx
// app/dashboard/page.tsx — đây là Server Component theo mặc định
import { db } from "@/lib/database";
import { headers } from "next/headers";
 
export default async function DashboardPage() {
  const headerList = await headers();
  const userId = headerList.get("x-user-id");
 
  // Truy vấn database trực tiếp. Không cần API route.
  const user = await db.user.findUnique({
    where: { id: userId },
  });
 
  const recentOrders = await db.order.findMany({
    where: { userId: user.id },
    orderBy: { createdAt: "desc" },
    take: 10,
  });
 
  return (
    <div>
      <h1>Welcome back, {user.name}</h1>
      <OrderList orders={recentOrders} />
    </div>
  );
}

Không useEffect. Không quản lý loading state. Không API route để kết nối mọi thứ. Component chính là lớp data. Đây là lợi ích lớn nhất của RSC và cũng là thứ mà lúc đầu tôi thấy khó chịu nhất, vì tôi cứ nghĩ "nhưng sự phân tách ở đâu?"

Sự phân tách chính là ranh giới "use client". Mọi thứ phía trên nó là server. Mọi thứ phía dưới nó là client. Đó là kiến trúc của bạn.

Ranh Giới Server/Client#

Đây là nơi hiểu biết của hầu hết mọi người sụp đổ, và cũng là nơi tôi dành phần lớn thời gian debug trong vài tháng đầu.

Directive "use client"#

Directive "use client" ở đầu file đánh dấu mọi thứ được export từ file đó là Client Component. Nó là annotation ở cấp module, không phải cấp component.

tsx
// src/components/Counter.tsx
"use client";
 
import { useState } from "react";
 
// Toàn bộ file này giờ là "lãnh thổ client"
export function Counter({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}
 
// Đây CŨNG LÀ Client Component vì nó nằm trong cùng file
export function ResetButton({ onReset }: { onReset: () => void }) {
  return <button onClick={onReset}>Reset</button>;
}

Sai lầm phổ biến: đặt "use client" trong barrel file (index.ts) re-export mọi thứ. Chúc mừng, bạn vừa biến toàn bộ thư viện component thành client-side. Tôi đã thấy các team vô tình ship 200KB JavaScript theo cách này.

Những Gì Có Thể Vượt Qua Ranh Giới#

Đây là quy tắc sẽ cứu bạn: mọi thứ vượt qua ranh giới server-client phải serializable được sang JSON.

Serializable được:

  • Strings, numbers, booleans, null, undefined
  • Arrays và plain objects (chứa giá trị serializable)
  • Dates (được serialize thành chuỗi ISO)
  • Server Components (dưới dạng JSX — chúng ta sẽ nói đến phần này)
  • FormData
  • Typed arrays, ArrayBuffer

KHÔNG serializable được:

  • Functions (bao gồm event handlers)
  • Classes (instances của custom classes)
  • Symbols
  • DOM nodes
  • Streams (trong hầu hết ngữ cảnh)

Điều này có nghĩa bạn không thể làm thế này:

tsx
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList"; // Client Component
 
export default async function Page() {
  const items = await getItems();
 
  return (
    <ItemList
      items={items}
      // LỖI: Functions không serializable được
      onItemClick={(id) => console.log(id)}
      // LỖI: Class instances không serializable được
      formatter={new Intl.NumberFormat("en-US")}
    />
  );
}

Cách sửa không phải biến page thành Client Component. Cách sửa là đẩy tương tác xuống dưới và data fetching lên trên:

tsx
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
 
export default async function Page() {
  const items = await getItems();
 
  // Chỉ truyền dữ liệu serializable
  return <ItemList items={items} locale="en-US" />;
}
tsx
// src/components/ItemList.tsx (Client Component)
"use client";
 
import { useState, useMemo } from "react";
 
interface Item {
  id: string;
  name: string;
  price: number;
}
 
export function ItemList({ items, locale }: { items: Item[]; locale: string }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);
 
  // Tạo formatter ở phía client
  const formatter = useMemo(
    () => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
    [locale]
  );
 
  return (
    <ul>
      {items.map((item) => (
        <li
          key={item.id}
          onClick={() => setSelectedId(item.id)}
          className={selectedId === item.id ? "selected" : ""}
        >
          {item.name} — {formatter.format(item.price)}
        </li>
      ))}
    </ul>
  );
}

Hiểu Lầm Về "Island"#

Ban đầu tôi nghĩ Client Components như những "đảo" — những mảnh tương tác nhỏ trong biển nội dung server-rendered. Điều đó đúng một phần nhưng bỏ sót một chi tiết quan trọng: một Client Component có thể render Server Components nếu chúng được truyền qua children hoặc props.

Điều này có nghĩa ranh giới không phải bức tường cứng. Nó giống một màng lọc hơn. Nội dung server-rendered có thể chảy xuyên qua Client Components thông qua pattern children. Chúng ta sẽ đào sâu vào phần này ở mục composition.

Các Pattern Data Fetching#

RSC thay đổi data fetching một cách căn bản. Không còn useEffect + useState + loading states cho dữ liệu đã biết tại thời điểm render. Nhưng các pattern mới cũng có những cạm bẫy riêng.

Fetch Cơ Bản Với Caching#

Trong Server Component, bạn chỉ cần fetch. Next.js mở rộng fetch toàn cục để thêm caching:

tsx
// app/products/page.tsx
export default async function ProductsPage() {
  // Cached theo mặc định — cùng URL trả về kết quả cached
  const res = await fetch("https://api.example.com/products");
  const products = await res.json();
 
  return (
    <div>
      {products.map((product: Product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

Kiểm soát hành vi caching một cách tường minh:

tsx
// Revalidate mỗi 60 giây (hành vi giống ISR)
const res = await fetch("https://api.example.com/products", {
  next: { revalidate: 60 },
});
 
// Không caching — luôn lấy dữ liệu mới
const res = await fetch("https://api.example.com/user/profile", {
  cache: "no-store",
});
 
// Cache với tags cho revalidation theo yêu cầu
const res = await fetch("https://api.example.com/products", {
  next: { tags: ["products"] },
});

Sau đó bạn có thể revalidate theo tag từ Server Action:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function refreshProducts() {
  revalidateTag("products");
}

Data Fetching Song Song#

Sai lầm về hiệu suất phổ biến nhất mà tôi thấy: data fetching tuần tự khi song song hoàn toàn ổn.

Xấu — tuần tự (waterfalls):

tsx
// app/dashboard/page.tsx — ĐỪNG LÀM THẾ NÀY
export default async function Dashboard() {
  const user = await getUser();           // 200ms
  const orders = await getOrders();       // 300ms
  const notifications = await getNotifications(); // 150ms
  // Tổng: 650ms — mỗi cái đợi cái trước
 
  return (
    <div>
      <UserInfo user={user} />
      <OrderList orders={orders} />
      <NotificationBell notifications={notifications} />
    </div>
  );
}

Tốt — song song:

tsx
// app/dashboard/page.tsx — HÃY LÀM THẾ NÀY
export default async function Dashboard() {
  // Cả ba chạy đồng thời
  const [user, orders, notifications] = await Promise.all([
    getUser(),        // 200ms
    getOrders(),      // 300ms (chạy song song)
    getNotifications(), // 150ms (chạy song song)
  ]);
  // Tổng: ~300ms — bị giới hạn bởi request chậm nhất
 
  return (
    <div>
      <UserInfo user={user} />
      <OrderList orders={orders} />
      <NotificationBell notifications={notifications} />
    </div>
  );
}

Còn tốt hơn — song song với Suspense boundaries độc lập:

tsx
// app/dashboard/page.tsx — TỐT NHẤT
import { Suspense } from "react";
 
export default function Dashboard() {
  // Lưu ý: component này KHÔNG async — nó uỷ quyền cho children
  return (
    <div>
      <Suspense fallback={<UserInfoSkeleton />}>
        <UserInfo />
      </Suspense>
      <Suspense fallback={<OrderListSkeleton />}>
        <OrderList />
      </Suspense>
      <Suspense fallback={<NotificationsSkeleton />}>
        <Notifications />
      </Suspense>
    </div>
  );
}
 
// Mỗi component fetch dữ liệu riêng
async function UserInfo() {
  const user = await getUser();
  return <div>{user.name}</div>;
}
 
async function OrderList() {
  const orders = await getOrders();
  return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
 
async function Notifications() {
  const notifications = await getNotifications();
  return <span>({notifications.length})</span>;
}

Pattern cuối cùng này mạnh nhất vì mỗi phần tải độc lập. Người dùng thấy nội dung ngay khi nó sẵn sàng, không phải tất-cả-hoặc-không-gì-cả. Các phần nhanh không phải đợi các phần chậm.

Request Deduplication#

Next.js tự động loại bỏ trùng lặp các lệnh fetch có cùng URL và options trong một render pass. Điều này có nghĩa bạn không cần đưa data fetching lên trên để tránh request thừa:

tsx
// Cả hai component này có thể fetch cùng URL
// và Next.js chỉ thực hiện MỘT HTTP request thực tế
 
async function Header() {
  const user = await fetch("/api/user").then(r => r.json());
  return <nav>Welcome, {user.name}</nav>;
}
 
async function Sidebar() {
  // Cùng URL — tự động dedup, không phải request thứ hai
  const user = await fetch("/api/user").then(r => r.json());
  return <aside>Role: {user.role}</aside>;
}

Lưu ý quan trọng: điều này chỉ hoạt động với fetch. Nếu bạn dùng ORM hoặc database client trực tiếp, bạn cần dùng hàm cache() của React:

tsx
import { cache } from "react";
import { db } from "@/lib/database";
 
// Wrap hàm data với cache()
// Giờ nhiều lần gọi trong cùng một render = một truy vấn thực tế
export const getUser = cache(async (userId: string) => {
  return db.user.findUnique({ where: { id: userId } });
});

cache() loại bỏ trùng lặp trong suốt vòng đời của một server request. Nó không phải persistent cache — nó là memoization theo từng request. Sau khi request kết thúc, giá trị cached bị garbage collect.

Các Pattern Composition Component#

Đây là nơi RSC trở nên thực sự tinh tế, một khi bạn hiểu các pattern. Và thực sự khó hiểu, cho đến khi bạn hiểu.

Pattern "Children Như Lỗ Hổng"#

Đây là pattern composition quan trọng nhất trong RSC và tôi mất hàng tuần mới thực sự thấm. Vấn đề như sau: bạn có một Client Component cung cấp layout hoặc tương tác, và bạn muốn render Server Components bên trong nó.

Bạn không thể import Server Component vào file Client Component. Ngay khi bạn thêm "use client", mọi thứ trong module đó là client-side. Nhưng bạn có thể truyền Server Components qua children:

tsx
// src/components/Sidebar.tsx — Client Component
"use client";
 
import { useState } from "react";
 
export function Sidebar({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(true);
 
  return (
    <aside className={isOpen ? "w-64" : "w-0"}>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen ? "Close" : "Open"}
      </button>
      {isOpen && (
        <div className="sidebar-content">
          {/* Các children này có thể là Server Components! */}
          {children}
        </div>
      )}
    </aside>
  );
}
tsx
// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // Server Component
import { UserProfile } from "@/components/UserProfile"; // Server Component
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <Sidebar>
        {/* Đây là Server Components, được truyền qua Client Component */}
        <UserProfile />
        <NavigationLinks />
      </Sidebar>
      <main>{children}</main>
    </div>
  );
}

Tại sao điều này hoạt động? Vì Server Components (UserProfile, NavigationLinks) được render trên server trước, sau đó output của chúng (RSC payload) được truyền dưới dạng children cho Client Component. Client Component không cần biết chúng là Server Components — nó chỉ nhận được các React nodes đã được render sẵn.

Hãy nghĩ children như một "lỗ hổng" trong Client Component nơi nội dung server-rendered có thể chảy qua.

Truyền Server Components Qua Props#

Pattern children tổng quát hoá cho bất kỳ prop nào nhận React.ReactNode:

tsx
// src/components/TabLayout.tsx — Client Component
"use client";
 
import { useState } from "react";
 
interface TabLayoutProps {
  tabs: { label: string; content: React.ReactNode }[];
}
 
export function TabLayout({ tabs }: TabLayoutProps) {
  const [activeTab, setActiveTab] = useState(0);
 
  return (
    <div>
      <div className="tab-bar" role="tablist">
        {tabs.map((tab, i) => (
          <button
            key={i}
            role="tab"
            aria-selected={i === activeTab}
            onClick={() => setActiveTab(i)}
            className={i === activeTab ? "active" : ""}
          >
            {tab.label}
          </button>
        ))}
      </div>
      <div role="tabpanel">{tabs[activeTab].content}</div>
    </div>
  );
}
tsx
// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings";   // Server Component — có thể fetch data
import { BillingSettings } from "./BillingSettings";    // Server Component — có thể fetch data
import { SecuritySettings } from "./SecuritySettings";  // Server Component — có thể fetch data
 
export default function SettingsPage() {
  return (
    <TabLayout
      tabs={[
        { label: "Profile", content: <ProfileSettings /> },
        { label: "Billing", content: <BillingSettings /> },
        { label: "Security", content: <SecuritySettings /> },
      ]}
    />
  );
}

Mỗi component settings có thể là async Server Component fetch dữ liệu riêng. Client Component (TabLayout) chỉ xử lý chuyển tab. Đây là một pattern cực kỳ mạnh mẽ.

Async Server Components#

Server Components có thể là async. Đây là điều rất quan trọng vì nó nghĩa là data fetching xảy ra trong quá trình rendering, không phải như side effect:

tsx
// Đây là hợp lệ và đẹp
async function BlogPost({ slug }: { slug: string }) {
  const post = await db.post.findUnique({ where: { slug } });
 
  if (!post) return notFound();
 
  const author = await db.user.findUnique({ where: { id: post.authorId } });
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {author.name}</p>
      <div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
    </article>
  );
}

Client Components không thể là async. Nếu bạn thử biến component "use client" thành async, React sẽ ném lỗi. Đây là ràng buộc cứng.

Suspense Boundaries: Primitive Cho Streaming#

Suspense là cách bạn có được streaming trong RSC. Không có Suspense boundaries, toàn bộ trang phải đợi async component chậm nhất. Với chúng, mỗi phần stream độc lập:

tsx
// app/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { ProductGrid } from "@/components/ProductGrid";
import { ReviewCarousel } from "@/components/ReviewCarousel";
import { RecommendationEngine } from "@/components/RecommendationEngine";
 
export default function HomePage() {
  return (
    <main>
      {/* Tĩnh — render ngay lập tức */}
      <HeroSection />
 
      {/* Data nhanh — hiện nhanh */}
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid />
      </Suspense>
 
      {/* Tốc độ trung bình — hiện khi sẵn sàng */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewCarousel />
      </Suspense>
 
      {/* Chậm (dùng ML) — hiện cuối cùng, không chặn phần còn lại */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <RecommendationEngine />
      </Suspense>
    </main>
  );
}

Người dùng thấy HeroSection ngay lập tức, sau đó ProductGrid stream vào, rồi reviews, rồi recommendations. Mỗi Suspense boundary là một điểm streaming độc lập.

Lồng Suspense boundaries cũng hợp lệ và hữu ích:

tsx
<Suspense fallback={<DashboardSkeleton />}>
  <Dashboard>
    <Suspense fallback={<ChartSkeleton />}>
      <RevenueChart />
    </Suspense>
    <Suspense fallback={<TableSkeleton />}>
      <RecentTransactions />
    </Suspense>
  </Dashboard>
</Suspense>

Nếu Dashboard nhanh nhưng RevenueChart chậm, Suspense ngoài resolve trước (hiện dashboard shell), và Suspense bên trong cho chart resolve sau.

Error Boundaries Với Suspense#

Kết hợp Suspense với error.tsx cho UI bền bỉ:

tsx
// app/dashboard/error.tsx — Client Component (bắt buộc)
"use client";
 
export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="p-8 text-center">
      <h2>Something went wrong loading the dashboard</h2>
      <p className="text-gray-500">{error.message}</p>
      <button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
        Try again
      </button>
    </div>
  );
}

File error.tsx tự động wrap route segment tương ứng trong React Error Boundary. Nếu bất kỳ Server Component nào trong segment đó throw lỗi, UI lỗi sẽ hiện thay vì crash toàn bộ trang.

Khi Nào Dùng Loại Nào: Cây Quyết Định#

Sau khi xây dựng nhiều ứng dụng production với RSC, tôi đã hình thành một framework quyết định rõ ràng. Đây là quy trình tư duy thực tế tôi áp dụng cho mỗi component:

Bắt Đầu Với Server Components (Mặc Định)#

Mọi component nên là Server Component trừ khi có lý do cụ thể không thể. Đây là quy tắc quan trọng nhất.

Biến Thành Client Component Khi:#

1. Nó dùng browser-only APIs

tsx
"use client";
// window, document, navigator, localStorage, v.v.
function GeoLocation() {
  const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
 
  useEffect(() => {
    navigator.geolocation.getCurrentPosition(
      (pos) => setCoords(pos.coords)
    );
  }, []);
 
  return coords ? <p>Lat: {coords.latitude}</p> : <p>Loading location...</p>;
}

2. Nó dùng React hooks cần state hoặc effects

useState, useEffect, useReducer, useRef (cho mutable refs), useContext — bất kỳ cái nào trong số này đều yêu cầu "use client".

tsx
"use client";
function SearchBar() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);
 
  // Component này BẮT BUỘC phải là Client Component vì nó
  // dùng useState và quản lý input người dùng
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

3. Nó gắn event handlers

onClick, onChange, onSubmit, onMouseEnter — bất kỳ hành vi tương tác nào đều có nghĩa client-side.

4. Nó dùng thư viện client-side

Framer Motion, React Hook Form, Zustand, React Query (cho client-side fetching), bất kỳ thư viện charting nào render vào canvas hoặc SVG tương tác.

Giữ Là Server Component Khi:#

  • Nó chỉ hiển thị dữ liệu (không có tương tác người dùng)
  • Nó fetch dữ liệu từ database hoặc API
  • Nó truy cập tài nguyên backend (file system, biến môi trường chứa secrets)
  • Nó import dependencies lớn mà client không cần (markdown parsers, syntax highlighters, thư viện date cho formatting)
  • Nó render nội dung tĩnh hoặc bán tĩnh

Quyết Định Thực Tế Trong Thực Hành#

Đây là ví dụ cụ thể. Tôi đang xây dựng trang sản phẩm:

ProductPage (Server)
├── ProductBreadcrumbs (Server) — navigation tĩnh, không tương tác
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│   ├── ProductTitle (Server) — chỉ text
│   ├── ProductPrice (Server) — số được format, không tương tác
│   └── AddToCartButton (Client) — onClick, quản lý cart state
├── ProductDescription (Server) — rendered markdown
├── Suspense
│   └── RelatedProducts (Server) — async data fetch, API chậm
└── Suspense
    └── ProductReviews (Server)
        └── ReviewForm (Client) — form có validation

Chú ý pattern: page shell và các phần nặng về data là Server Components. Các đảo tương tác (ImageGallery, AddToCartButton, ReviewForm) là Client Components. Các phần chậm (RelatedProducts, ProductReviews) được wrap trong Suspense.

Đây không phải lý thuyết. Đây là cách cây component của tôi thực sự trông như vậy.

Các Sai Lầm Phổ Biến (Tôi Đã Mắc Hết)#

Sai Lầm 1: Biến Mọi Thứ Thành Client Component#

Con đường ít kháng cự nhất khi migrate từ Pages Router hoặc Create React App là gắn "use client" lên mọi thứ. Nó hoạt động! Không có gì hỏng! Bạn cũng đang ship toàn bộ cây component dưới dạng JavaScript và không nhận được lợi ích RSC nào.

Tôi đã thấy codebase mà root layout có "use client". Lúc đó bạn đang chạy một ứng dụng React client-side với thêm nhiều bước thừa.

Cách sửa: Bắt đầu với Server Components. Chỉ thêm "use client" khi compiler bảo bạn cần (vì bạn dùng hook hoặc event handler). Đẩy "use client" xuống xa nhất có thể trong cây.

Sai Lầm 2: Prop Drilling Qua Ranh Giới#

tsx
// XẤU: fetch data trong Server Component, rồi truyền qua
// nhiều Client Components
 
// app/page.tsx (Server)
export default async function Page() {
  const user = await getUser();
  const settings = await getSettings();
  const theme = await getTheme();
 
  return (
    <ClientShell user={user} settings={settings} theme={theme}>
      <ClientContent user={user} settings={settings}>
        <ClientWidget user={user} />
      </ClientContent>
    </ClientShell>
  );
}

Mỗi mảnh dữ liệu bạn truyền qua ranh giới đều được serialize vào RSC payload. Truyền cùng object năm lần? Nó có trong payload năm lần. Tôi đã thấy RSC payload phình lên hàng megabyte vì điều này.

Cách sửa: Dùng composition. Truyền Server Components qua children thay vì truyền data qua props:

tsx
// TỐT: Server Components fetch data riêng, truyền qua dưới dạng children
 
// app/page.tsx (Server)
export default function Page() {
  return (
    <ClientShell>
      <UserInfo />      {/* Server Component — fetch data riêng */}
      <Settings />      {/* Server Component — fetch data riêng */}
      <ClientWidget>
        <UserAvatar />  {/* Server Component — fetch data riêng */}
      </ClientWidget>
    </ClientShell>
  );
}

Sai Lầm 3: Không Dùng Suspense#

Không có Suspense, Time to First Byte (TTFB) của trang bị giới hạn bởi data fetch chậm nhất. Tôi có một trang dashboard mất 4 giây để tải vì một truy vấn analytics chậm, dù phần còn lại của dữ liệu trang đã sẵn sàng trong 200ms.

tsx
// XẤU: mọi thứ đợi mọi thứ
export default async function Dashboard() {
  const stats = await getStats();         // 200ms
  const chart = await getChartData();     // 300ms
  const analytics = await getAnalytics(); // 4000ms ← chặn mọi thứ
 
  return (
    <div>
      <Stats data={stats} />
      <Chart data={chart} />
      <Analytics data={analytics} />
    </div>
  );
}
tsx
// TỐT: analytics tải độc lập
export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <Chart />
      </Suspense>
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics /> {/* Mất 4s nhưng không chặn phần còn lại */}
      </Suspense>
    </div>
  );
}

Sai Lầm 4: Lỗi Serialization Khi Runtime#

Cái này đặc biệt đau đớn vì bạn thường không bắt được cho đến production. Bạn truyền thứ gì đó không serializable qua ranh giới và nhận lỗi khó hiểu:

Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.

Thủ phạm phổ biến:

  • Truyền Date objects (dùng .toISOString() thay vào đó)
  • Truyền Map hoặc Set (chuyển sang arrays/objects)
  • Truyền class instances từ ORM (dùng .toJSON() hoặc spread thành plain objects)
  • Truyền functions (chuyển logic vào Client Component hoặc dùng Server Actions)
  • Truyền kết quả Prisma model có trường Decimal (chuyển sang number hoặc string)
tsx
// XẤU
const user = await prisma.user.findUnique({ where: { id } });
// user có thể có trường không serializable (Decimal, BigInt, v.v.)
return <ClientProfile user={user} />;
 
// TỐT
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
  id: user.id,
  name: user.name,
  email: user.email,
  balance: user.balance.toNumber(), // Decimal → number
  createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;

Sai Lầm 5: Dùng Context Cho Mọi Thứ#

useContext chỉ hoạt động trong Client Components. Nếu bạn cố dùng React context trong Server Component, nó sẽ không hoạt động. Tôi đã thấy người ta biến toàn bộ app thành Client Component chỉ để dùng theme context.

Cách sửa: Với themes và global state khác, dùng CSS variables được set ở phía server, hoặc dùng hàm cookies() / headers():

tsx
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
 
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  const theme = cookieStore.get("theme")?.value ?? "light";
 
  return (
    <html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
      <body>{children}</body>
    </html>
  );
}

Với state thực sự client-side (auth tokens, giỏ hàng, dữ liệu real-time), tạo một Client Component provider mỏng ở cấp phù hợp — không phải root:

tsx
// src/providers/CartProvider.tsx
"use client";
 
import { createContext, useContext, useState } from "react";
 
const CartContext = createContext<CartContextType | null>(null);
 
export function CartProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = useState<CartItem[]>([]);
 
  return (
    <CartContext.Provider value={{ items, setItems }}>
      {children}
    </CartContext.Provider>
  );
}
 
export function useCart() {
  const context = useContext(CartContext);
  if (!context) throw new Error("useCart must be used within CartProvider");
  return context;
}
tsx
// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
 
export default function ShopLayout({ children }: { children: React.ReactNode }) {
  // CartProvider là Client Component, nhưng children chảy qua dưới dạng server content
  return <CartProvider>{children}</CartProvider>;
}

Sai Lầm 6: Bỏ Qua Tác Động Lên Bundle Size#

Một trong những lợi ích lớn nhất của RSC là code Server Component không bao giờ ship tới client. Nhưng bạn cần suy nghĩ về điều này một cách chủ động. Nếu bạn có component dùng markdown parser 50KB và chỉ hiển thị nội dung đã render — nó nên là Server Component. Parser ở lại server, và chỉ HTML output đi tới client.

tsx
// Server Component — marked ở lại server
import { marked } from "marked"; // Thư viện 50KB — không bao giờ ship tới client
 
async function BlogContent({ slug }: { slug: string }) {
  const post = await getPost(slug);
  const html = marked(post.markdown);
 
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

Nếu bạn biến nó thành Client Component, marked sẽ được ship tới trình duyệt. Vô ích. Người dùng sẽ tải 50KB JavaScript chỉ để render nội dung đáng lẽ đã có thể là HTML từ đầu.

Kiểm tra bundle với @next/bundle-analyzer. Kết quả có thể khiến bạn bất ngờ.

Chiến Lược Caching#

Caching trong Next.js 15+ đã được đơn giản hoá đáng kể so với các phiên bản trước, nhưng vẫn có các lớp riêng biệt cần hiểu.

Hàm cache() (React)#

cache() của React dành cho deduplication theo từng request, không phải persistent caching:

tsx
import { cache } from "react";
 
export const getCurrentUser = cache(async () => {
  const session = await getSession();
  if (!session) return null;
 
  return db.user.findUnique({ where: { id: session.userId } });
});
 
// Gọi hàm này bất cứ đâu trong cây component trong một request.
// Chỉ một truy vấn database thực tế sẽ thực thi.

Phạm vi này giới hạn trong một server request. Khi request kết thúc, giá trị cached biến mất. Đây là memoization, không phải caching.

unstable_cache (Next.js)#

Để caching lâu dài giữa các request, dùng unstable_cache (tên đã "unstable" mãi, nhưng nó hoạt động tốt trong production):

tsx
import { unstable_cache } from "next/cache";
 
const getCachedProducts = unstable_cache(
  async (categoryId: string) => {
    return db.product.findMany({
      where: { categoryId },
      include: { images: true },
    });
  },
  ["products-by-category"], // tiền tố cache key
  {
    revalidate: 3600, // revalidate mỗi giờ
    tags: ["products"], // cho revalidation theo yêu cầu
  }
);
 
// Sử dụng trong Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
  const products = await getCachedProducts(categoryId);
  return <Grid items={products} />;
}

Để invalidate:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function onProductUpdate() {
  revalidateTag("products");
}

Static vs Dynamic Rendering#

Next.js quyết định một route là static hay dynamic dựa trên những gì bạn dùng trong đó:

Static (render tại build time, cached):

  • Không có hàm dynamic (cookies(), headers(), searchParams)
  • Tất cả lệnh fetch đều bật caching
  • Không có export const dynamic = "force-dynamic"

Dynamic (render mỗi request):

  • Dùng cookies(), headers(), hoặc searchParams
  • Dùng fetch với cache: "no-store"
  • export const dynamic = "force-dynamic"
  • Dùng connection() hoặc after() từ next/server

Bạn có thể kiểm tra route nào static vs dynamic bằng cách chạy next build — nó hiện legend ở cuối:

Route (app)                    Size     First Load JS
┌ ○ /                          5.2 kB   92 kB
├ ○ /about                     1.1 kB   88 kB
├ ● /blog/[slug]               2.3 kB   89 kB
├ λ /dashboard                 4.1 kB   91 kB
└ λ /api/products              0 B      87 kB

○ Static   ● SSG   λ Dynamic

Hệ Thống Phân Cấp Cache#

Hãy nghĩ về caching theo các lớp:

1. React cache()          — theo request, in-memory, dedup tự động
2. fetch() cache          — giữa các request, tự động cho GET requests
3. unstable_cache()       — giữa các request, cho thao tác không phải fetch
4. Full Route Cache       — HTML đã render được cache tại build/revalidation time
5. Router Cache (client)  — cache trong trình duyệt của các route đã truy cập

Mỗi lớp phục vụ mục đích khác nhau. Bạn không phải lúc nào cũng cần tất cả, nhưng hiểu lớp nào đang hoạt động giúp debug vấn đề "tại sao dữ liệu không cập nhật?".

Chiến Lược Caching Thực Tế#

Đây là những gì tôi thực sự làm trong production:

tsx
// lib/data/products.ts
 
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
 
// Dedup theo request: gọi nhiều lần trong một render,
// chỉ một truy vấn DB chạy
export const getProductById = cache(async (id: string) => {
  return db.product.findUnique({
    where: { id },
    include: { category: true, images: true },
  });
});
 
// Cache giữa các request: kết quả lưu giữa các request,
// revalidate mỗi 5 phút hoặc theo yêu cầu qua tag
export const getPopularProducts = unstable_cache(
  async () => {
    return db.product.findMany({
      orderBy: { salesCount: "desc" },
      take: 20,
      include: { images: true },
    });
  },
  ["popular-products"],
  { revalidate: 300, tags: ["products"] }
);
 
// Không caching: luôn mới (cho dữ liệu theo user)
export const getUserCart = cache(async (userId: string) => {
  // cache() ở đây chỉ cho dedup theo request, không phải lưu trữ
  return db.cart.findUnique({
    where: { userId },
    include: { items: { include: { product: true } } },
  });
});

Quy tắc chung: dữ liệu công khai ít thay đổi dùng unstable_cache. Dữ liệu theo user dùng cache() chỉ để dedup. Dữ liệu real-time không caching gì cả.

Server Actions: Cầu Nối Trở Lại#

Server Actions xứng đáng có phần riêng vì chúng hoàn thiện câu chuyện RSC. Chúng là cách Client Components giao tiếp ngược lại với server mà không cần API routes.

tsx
// app/actions/newsletter.ts
"use server";
 
import { db } from "@/lib/database";
import { z } from "zod";
 
const emailSchema = z.string().email();
 
export async function subscribeToNewsletter(formData: FormData) {
  const email = formData.get("email");
 
  const result = emailSchema.safeParse(email);
  if (!result.success) {
    return { error: "Invalid email address" };
  }
 
  try {
    await db.subscriber.create({
      data: { email: result.data },
    });
    return { success: true };
  } catch (e) {
    if (e instanceof Error && e.message.includes("Unique constraint")) {
      return { error: "Already subscribed" };
    }
    return { error: "Something went wrong" };
  }
}
tsx
// src/components/NewsletterForm.tsx
"use client";
 
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
 
export function NewsletterForm() {
  const [state, formAction, isPending] = useActionState(
    async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
      return subscribeToNewsletter(formData);
    },
    null
  );
 
  return (
    <form action={formAction}>
      <input
        type="email"
        name="email"
        required
        placeholder="you@example.com"
        disabled={isPending}
      />
      <button type="submit" disabled={isPending}>
        {isPending ? "Subscribing..." : "Subscribe"}
      </button>
      {state?.error && <p className="text-red-500">{state.error}</p>}
      {state?.success && <p className="text-green-500">Subscribed!</p>}
    </form>
  );
}

Server Actions là câu trả lời cho "làm sao mutate dữ liệu?" trong thế giới RSC. Chúng thay thế hầu hết API routes cho form submissions, mutations, và side effects.

Các quy tắc quan trọng cho Server Actions:

  • Luôn validate input (hàm có thể gọi từ client — hãy coi nó như API endpoint)
  • Luôn trả về dữ liệu serializable
  • Chúng chạy trên server, nên bạn có thể truy cập databases, file systems, secrets
  • Chúng có thể gọi revalidatePath() hoặc revalidateTag() để cập nhật dữ liệu cached sau mutations

Các Pattern Migration#

Nếu bạn có ứng dụng React sẵn có (Pages Router, Create React App, Vite), chuyển sang RSC không nhất thiết phải viết lại. Đây là cách tôi tiếp cận.

Bước 1: Phân Loại Components#

Duyệt qua cây component và phân loại mọi thứ:

Component            State?  Effects?  Events?  → Quyết Định
─────────────────────────────────────────────────────────
Header               No      No        No       → Server
NavigationMenu       No      No        Yes      → Client (mobile toggle)
Footer               No      No        No       → Server
BlogPost             No      No        No       → Server
SearchBar            Yes     Yes       Yes      → Client
ProductCard          No      No        Yes      → Client (onClick) hoặc tách
UserAvatar           No      No        No       → Server
CommentForm          Yes     Yes       Yes      → Client
Sidebar              Yes     No        Yes      → Client (collapse toggle)
MarkdownRenderer     No      No        No       → Server (lợi dependency lớn)
DataTable            Yes     Yes       Yes      → Client (sorting, filtering)

Bước 2: Chuyển Data Fetching Lên Trên#

Thay đổi kiến trúc lớn nhất là chuyển data fetching từ useEffect trong components sang async Server Components. Đây là nơi công sức migration thực sự nằm.

Trước:

tsx
// Pattern cũ — data fetching trong Client Component
"use client";
 
function ProductPage({ id }: { id: string }) {
  const [product, setProduct] = useState<Product | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then(res => res.json())
      .then(data => setProduct(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [id]);
 
  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  if (!product) return <NotFound />;
 
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} />
    </div>
  );
}

Sau:

tsx
// Pattern mới — Server Component fetch, Client Component tương tác
 
// app/products/[id]/page.tsx (Server Component)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
 
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });
 
  if (!product) notFound();
 
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} price={product.price} />
    </div>
  );
}

Không quản lý loading state. Không error state. Không useEffect. Framework xử lý tất cả thông qua Suspense và error boundaries.

Bước 3: Tách Component Tại Ranh Giới Tương Tác#

Nhiều component hầu hết tĩnh với một phần tương tác nhỏ. Hãy tách chúng:

Trước (một Client Component lớn):

tsx
"use client";
 
function ProductCard({ product }: { product: Product }) {
  const [isFavorite, setIsFavorite] = useState(false);
 
  return (
    <div className="card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <span>${product.price}</span>
      <button onClick={() => setIsFavorite(!isFavorite)}>
        {isFavorite ? "♥" : "♡"}
      </button>
    </div>
  );
}

Sau (Server Component với đảo Client nhỏ):

tsx
// ProductCard.tsx (Server Component)
import { FavoriteButton } from "./FavoriteButton";
 
function ProductCard({ product }: { product: Product }) {
  return (
    <div className="card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <span>${product.price}</span>
      <FavoriteButton productId={product.id} />
    </div>
  );
}
tsx
// FavoriteButton.tsx (Client Component)
"use client";
 
import { useState } from "react";
 
export function FavoriteButton({ productId }: { productId: string }) {
  const [isFavorite, setIsFavorite] = useState(false);
 
  return (
    <button
      onClick={() => setIsFavorite(!isFavorite)}
      aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
    >
      {isFavorite ? "♥" : "♡"}
    </button>
  );
}

Ảnh, tiêu đề, mô tả và giá giờ được server-rendered. Chỉ nút yêu thích nhỏ là Client Component. Ít JavaScript hơn, tải trang nhanh hơn.

Bước 4: Chuyển API Routes Sang Server Actions#

Nếu bạn có API routes tồn tại chỉ để phục vụ frontend riêng (không phải cho bên ngoài), hầu hết chúng có thể trở thành Server Actions:

Trước:

tsx
// app/api/contact/route.ts
export async function POST(request: Request) {
  const body = await request.json();
  // validate, lưu vào DB, gửi email
  return Response.json({ success: true });
}
 
// Client component
const response = await fetch("/api/contact", {
  method: "POST",
  body: JSON.stringify(formData),
});

Sau:

tsx
// app/actions/contact.ts
"use server";
 
export async function submitContactForm(formData: FormData) {
  // validate, lưu vào DB, gửi email
  return { success: true };
}
 
// Client component — chỉ cần gọi hàm trực tiếp
import { submitContactForm } from "@/app/actions/contact";

Giữ API routes cho: webhooks, API consumers bên ngoài, bất cứ thứ gì cần custom HTTP headers hoặc status codes, file uploads với streaming.

Bước 5: Test RSC Components#

Test Server Components yêu cầu cách tiếp cận hơi khác vì chúng có thể là async:

tsx
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
 
// Mock database
vi.mock("@/lib/database", () => ({
  db: {
    product: {
      findUnique: vi.fn(),
    },
  },
}));
 
describe("ProductPage", () => {
  it("renders product details", async () => {
    const { db } = await import("@/lib/database");
    vi.mocked(db.product.findUnique).mockResolvedValue({
      id: "1",
      name: "Test Product",
      description: "A great product",
      price: 29.99,
    });
 
    // Server Components là async — await JSX
    const jsx = await ProductPage({
      params: Promise.resolve({ id: "1" })
    });
    render(jsx);
 
    expect(screen.getByText("Test Product")).toBeInTheDocument();
    expect(screen.getByText("A great product")).toBeInTheDocument();
  });
});

Điểm khác biệt chính: bạn await hàm component vì nó là async. Sau đó render JSX kết quả. Mọi thứ khác hoạt động giống như React Testing Library truyền thống.

Cấu Trúc File Cho Dự Án RSC#

Đây là cấu trúc mà tôi đã hội tụ sau nhiều dự án. Nó mang tính chủ quan, nhưng nó hoạt động:

src/
├── app/
│   ├── layout.tsx              ← Root layout (Server Component)
│   ├── page.tsx                ← Trang chủ (Server Component)
│   ├── (marketing)/            ← Route group cho trang marketing
│   │   ├── about/page.tsx
│   │   └── pricing/page.tsx
│   ├── (app)/                  ← Route group cho app có auth
│   │   ├── layout.tsx          ← App shell với kiểm tra auth
│   │   ├── dashboard/
│   │   │   ├── page.tsx
│   │   │   ├── loading.tsx     ← Suspense fallback cho route này
│   │   │   └── error.tsx       ← Error boundary cho route này
│   │   └── settings/
│   │       └── page.tsx
│   └── actions/                ← Server Actions
│       ├── auth.ts
│       └── products.ts
├── components/
│   ├── ui/                     ← Shared UI primitives (phần lớn Client)
│   │   ├── Button.tsx          ← "use client"
│   │   ├── Dialog.tsx          ← "use client"
│   │   └── Card.tsx            ← Server Component (chỉ styling)
│   └── features/               ← Components theo tính năng
│       ├── products/
│       │   ├── ProductGrid.tsx     ← Server (async, fetch data)
│       │   ├── ProductCard.tsx     ← Server (presentational)
│       │   ├── ProductSearch.tsx   ← Client (useState, input)
│       │   └── AddToCart.tsx       ← Client (onClick, mutation)
│       └── blog/
│           ├── PostList.tsx        ← Server (async, fetch data)
│           ├── PostContent.tsx     ← Server (markdown rendering)
│           └── CommentSection.tsx  ← Client (form, real-time)
├── lib/
│   ├── data/                   ← Lớp truy cập dữ liệu
│   │   ├── products.ts         ← Truy vấn DB được wrap bằng cache()
│   │   └── users.ts
│   ├── database.ts
│   └── utils.ts
└── providers/
    ├── ThemeProvider.tsx        ← "use client" — wrap phần cần theme
    └── CartProvider.tsx         ← "use client" — chỉ wrap phần shop

Các nguyên tắc chính:

  • Server Components không có directive — chúng là mặc định
  • Client Components được đánh dấu tường minh — bạn nhìn là biết ngay
  • Data fetching nằm trong lib/data/ — được wrap bằng cache() hoặc unstable_cache
  • Server Actions nằm trong app/actions/ — đặt cạnh app, tách biệt rõ ràng
  • Providers wrap phần tối thiểu cần thiết — không phải toàn bộ app

Lời Kết#

React Server Components không chỉ là API mới. Chúng là cách suy nghĩ khác về nơi code chạy, nơi data sống, và cách các mảnh kết nối. Sự chuyển đổi mô hình tư duy là thực và cần thời gian.

Nhưng một khi nó "click" — một khi bạn ngừng chống lại ranh giới và bắt đầu thiết kế xung quanh nó — bạn sẽ có những ứng dụng nhanh hơn, đơn giản hơn, và dễ bảo trì hơn so với những gì chúng ta xây dựng trước đây. Ít JavaScript ship tới client. Data fetching không cần nghi thức rườm rà. Cây component trở thành kiến trúc.

Quá trình chuyển đổi xứng đáng. Chỉ cần biết rằng vài dự án đầu tiên sẽ cảm thấy khó chịu, và điều đó là bình thường. Bạn không đang vật lộn vì RSC tệ. Bạn đang vật lộn vì nó thực sự mới.

Bắt đầu với Server Components ở mọi nơi. Đẩy "use client" xuống tận lá. Wrap thứ chậm trong Suspense. Fetch data nơi nó được render. Compose thông qua children.

Đó là toàn bộ playbook. Mọi thứ khác chỉ là chi tiết.

Bài viết liên quan