تصور کن کاربر وارد صفحه جزئیات یک محصول میشود. عنوان، تصویر، قیمت و توضیحات محصول باید نمایش داده شوند؛ موجودی باید از دیتابیس خوانده شود؛ دکمه افزودن به سبد خرید باید کار کند و فیلترهای صفحه نیز باید به تعامل کاربر پاسخ دهند.
یک سؤال معماری مهم وجود دارد:
آیا واقعاً لازم است تمام این قابلیتها در مرورگر اجرا شوند؟
React Server Components یا RSC پاسخی متفاوت به این سؤال میدهد. بهجای اینکه کل رابط کاربری را به یک محیط اجرایی وابسته کنیم، میتوانیم مسئولیتها را بین سرور و مرورگر تقسیم کنیم.
اما این تقسیم مسئولیت دقیقاً چگونه اتفاق میافتد؟ چه چیزی در سرور اجرا میشود؟ چه چیزی به مرورگر میرسد؟ و React چگونه این دو محیط را به یک رابط کاربری واحد تبدیل میکند؟
برای پاسخ، مسیر یک درخواست واقعی را دنبال میکنیم.
مرورگر و سرور دو محیط اجرایی متفاوت هستند.
مرورگر به قابلیتهایی مثل window، localStorage، DOM، Event Handlerها و تعامل مستقیم کاربر دسترسی دارد.
سرور میتواند به دیتابیس، فایلهای سیستم، متغیرهای محیطی محرمانه و سرویسهای داخلی دسترسی داشته باشد.
مشکل زمانی ایجاد میشود که تلاش کنیم تمام مسئولیتها را در یک محیط قرار دهیم.
برای مثال، اگر یک صفحه فقط اطلاعات محصول را نمایش میدهد، چرا باید کد اتصال به دیتابیس یا کتابخانهای که فقط برای پردازش داده در سرور استفاده میشود، به مرورگر ارسال شود؟
از طرف دیگر، اگر کاربر روی دکمه افزودن به سبد خرید کلیک میکند، این تعامل به منطق سمت کلاینت نیاز دارد.
RSC این دو محیط را به دو بخش قابل ترکیب تقسیم میکند:
- Server Components: برای اجرای منطق و رندر در محیط سرور.
- Client Components: برای قابلیتهایی که به محیط مرورگر و تعامل کاربر نیاز دارند.
این تقسیمبندی را میتوان یک Network Boundary در درخت کامپوننتها در نظر گرفت؛ مرزی که مشخص میکند کدام بخش از برنامه در کدام محیط اجرا شود.
فرض کنیم کاربر وارد مسیر زیر میشود:
/products/keyboard
صفحه به اطلاعات زیر نیاز دارد:
- مشخصات محصول از دیتابیس
- اطلاعات فروشنده
- قیمت و موجودی
- دکمه افزودن به سبد خرید
- انتخاب تعداد محصول
در معماری RSC، همه این مسئولیتها لزوماً در مرورگر انجام نمیشوند.
یک تقسیمبندی مناسب میتواند اینگونه باشد:
ProductPage (Server)
│
├── ProductInformation (Server)
│
├── SellerInformation (Server)
│
├── ProductPrice (Server)
│
├── QuantitySelector (Client)
│
└── AddToCartButton (Client)
در این ساختار، اطلاعات محصول در سرور دریافت میشوند؛ اما انتخاب تعداد و افزودن به سبد خرید در مرورگر مدیریت میشوند.
این موضوع فقط یک تغییر در نامگذاری کامپوننتها نیست؛ بلکه روی محل اجرای کد، حجم JavaScript ارسالی و نحوه دریافت داده تأثیر میگذارد.
Server Component نوعی کامپوننت React است که پیش از ارسال خروجی به Client، در محیط جداگانهای اجرا میشود.
این محیط میتواند:
- هنگام Build روی سیستم CI باشد.
- هنگام هر درخواست روی Web Server اجرا شود.
بنابراین Server Component الزاماً به معنی اجرای مجدد کامپوننت در هر درخواست نیست. زمان اجرا به معماری Framework و نحوه استفاده از دادهها بستگی دارد.
مثال:
import { db } from "@/lib/db";
type Props = {
productId: string;
};
export default async function ProductInformation({
productId,
}: Props) {
const product = await db.product.findUnique({
where: {
id: productId,
},
select: {
name: true,
description: true,
price: true,
},
});
if (!product) {
return <p>Product not found.</p>;
}
return (
<section>
<h1>{product.name}</h1>
<p>{product.description}</p>
<strong>${product.price}</strong>
</section>
);
}
چند نکته در این مثال وجود دارد:
- کامپوننت میتواند
async باشد.
- اطلاعات مستقیماً از دیتابیس دریافت میشوند.
- نیازی به
useEffect برای دریافت اولیه اطلاعات نیست.
- کد اتصال به دیتابیس بخشی از Client Bundle نخواهد بود.
نکته مهم: RSC بهخودیخود Database، ORM یا Backend ایجاد نمیکند. فقط امکان اجرای کامپوننت در محیط سرور را فراهم میکند؛ زیرساخت دسترسی به داده را باید خودتان فراهم کنید.
اینجا به یکی از مفاهیم اصلی RSC میرسیم: RSC Payload.
وقتی Server Components اجرا میشوند، React نتیجه آنها را در یک قالب دادهای اختصاصی آماده میکند.
این خروجی صرفاً HTML معمولی نیست.
RSC Payload اطلاعاتی برای بازسازی و هماهنگسازی درخت کامپوننتها در اختیار Client قرار میدهد؛ از جمله:
- نتیجه رندر Server Components
- محل قرارگیری Client Components
- Reference مربوط به JavaScript کامپوننتهای Client
- Props موردنیاز برای Client Components
به زبان ساده، RSC Payload به React میگوید چه بخشهایی از رابط کاربری در سرور آماده شدهاند و کدام بخشها باید با کد کلاینت تکمیل شوند.
| مفهوم | مسئولیت |
|---|
| HTML | نمایش اولیه محتوا در مرورگر |
| RSC Payload | انتقال اطلاعات درخت Server/Client برای React |
| Client JavaScript | اجرای تعاملات و قابلیتهای سمت مرورگر |
این سه مورد را نباید با یکدیگر یکی دانست.
در Next.js App Router، بارگذاری اولیه صفحه از چند مرحله تشکیل میشود.
User Request
|
v
Next.js Server
|
v
Render Server Components
|
v
Generate RSC Payload
|
v
Pre-render HTML
|
v
Browser Receives Response
|
+------> Display HTML
|
+------> Reconcile RSC Tree
|
+------> Load Client JavaScript
|
v
Hydration
|
v
Interactive UI
این فرآیند را مرحلهبهمرحله بررسی کنیم.
مرحله اول: اجرای Server Components
سرور کامپوننتهایی را که در بخش Server قرار دارند اجرا میکند و دادههای موردنیاز را دریافت میکند.
مرحله دوم: تولید RSC Payload
React اطلاعات مربوط به درخت رندرشده و محل قرارگیری Client Components را آماده میکند.
مرحله سوم: تولید HTML اولیه
در Next.js، HTML اولیه از ترکیب خروجی Server Components و پیشرندر Client Components تولید میشود.
مرحله چهارم: نمایش صفحه
مرورگر میتواند HTML را نمایش دهد؛ حتی پیش از اینکه تعاملات کلاینت آماده باشند.
مرحله پنجم: Hydration
JavaScript مربوط به Client Components بارگذاری و اجرا میشود و React Event Handlerها را به DOM متصل میکند.
در نتیجه صفحه از یک خروجی اولیه قابل مشاهده به یک رابط کاربری تعاملی تبدیل میشود.
نکته مهم: Client Componentها در بارگذاری اولیه Next.js میتوانند برای تولید HTML روی سرور نیز رندر شوند؛ بنابراین Client Component به معنی «فقط اجرا در مرورگر» نیست.
فرض کنیم صفحه محصول را به شکل زیر نوشتهایم:
import ProductInformation from "./product-information";
import AddToCartButton from "./add-to-cart-button";
export default async function ProductPage() {
const product = await getProduct();
return (
<main>
<ProductInformation product={product} />
<AddToCartButton productId={product.id} />
</main>
);
}
در Next.js App Router این صفحه بهصورت پیشفرض Server Component است.
اما فایل دکمه به تعامل نیاز دارد:
"use client";
import { useState } from "react";
type Props = {
productId: string;
};
export default function AddToCartButton({
productId,
}: Props) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => setAdded(true)}>
{added ? "Added to cart" : "Add to cart"}
</button>
);
}
عبارت "use client" یک مرز در Client Module Graph تعریف میکند.
یعنی از این نقطه به بعد، Importهای آن فایل و وابستگیهای Client آن در گراف ماژول سمت کلاینت قرار میگیرند.
به همین دلیل، لازم نیست در تکتک فایلهای زیرمجموعه دوباره "use client" بنویسیم.
این ساختار را در نظر بگیر:
Dashboard (Client)
│
├── Header
├── Sidebar
├── Statistics
├── Chart
├── Search
└── Filters
اگر کل Dashboard را Client Component کنیم، بخشهای غیرتعاملی هم وارد Client Module Graph میشوند.
ساختار هدفمندتر:
Dashboard (Server)
│
├── Header (Server)
├── Sidebar (Server)
├── Statistics (Server)
├── Chart (Client)
├── Search (Client)
└── Filters (Client)
هدف این نیست که تعداد Client Components را به صفر برسانیم؛ هدف این است که مرز کلاینت را فقط جایی قرار دهیم که واقعاً به آن نیاز داریم.
فرض کنیم یک Modal در سمت کلاینت داریم و میخواهیم داخل آن اطلاعاتی را نمایش دهیم که از دیتابیس دریافت میشوند.
روش اشتباه:
"use client";
import CartContent from "./cart-content";
export default function CartModal() {
return (
<div>
<CartContent />
</div>
);
}
اگر CartContent یک Server Component باشد، Import کردن آن از یک Client Module باعث نمیشود کدش در سرور باقی بماند.
راه درست استفاده از Composition است.
کامپوننت Modal:
"use client";
import { useState } from "react";
type Props = {
children: React.ReactNode;
};
export default function CartModal({ children }: Props) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>
Open cart
</button>
{open && (
<section>
{children}
<button onClick={() => setOpen(false)}>
Close
</button>
</section>
)}
</>
);
}
کامپوننت سبد خرید:
import { db } from "@/lib/db";
export default async function CartContent() {
const items = await db.cartItem.findMany();
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
صفحه والد:
import CartModal from "./cart-modal";
import CartContent from "./cart-content";
export default function Page() {
return (
<CartModal>
<CartContent />
</CartModal>
);
}
در این روش، Server Component در والد Server رندر میشود و نتیجه آن بهعنوان children به Client Component داده میشود.
این الگو یکی از مهمترین ابزارهای طراحی معماری RSC است.
وقتی یک Server Component اطلاعاتی را به Client Component میدهد، باید به قوانین Serialization مربوط به React توجه کنیم.
مثال ساده:
<ProductActions
product={{
id: product.id,
name: product.name,
price: product.price,
}}
/>
این نوع دادهها برای انتقال مناسباند.
اما نباید فرض کنیم هر شیء JavaScript را میتوان بدون محدودیت از مرز Server و Client عبور داد.
برای مثال، ارسال یک اتصال دیتابیس، نمونه کلاس دلخواه یا شیء حاوی منابع سرور به Client Component الگوی درستی نیست.
بهتر است فقط دادههایی را منتقل کنید که واقعاً برای رابط کاربری لازم هستند.
بهجای ارسال کل رکورد دیتابیس:
<ClientProduct product={databaseProduct} />
یک مدل محدود و مشخص تعریف کنید:
type ProductViewModel = {
id: string;
name: string;
price: number;
available: boolean;
};
const productViewModel: ProductViewModel = {
id: product.id,
name: product.name,
price: product.price,
available: product.stock > 0,
};
این کار علاوه بر کنترل دادههای ارسالی، وابستگی رابط کاربری به ساختار داخلی دیتابیس را نیز کاهش میدهد.
در یک صفحه واقعی ممکن است اطلاعات اصلی سریع دریافت شوند، اما یک بخش جانبی به زمان بیشتری نیاز داشته باشد.
برای مثال:
- اطلاعات محصول: سریع
- مشخصات فروشنده: سریع
- نظرات کاربران: کندتر
- پیشنهادهای مرتبط: کندتر
آیا باید برای دریافت تمام این اطلاعات منتظر بمانیم؟
نه لزوماً.
با Streaming و Suspense میتوان بخشهای آماده را زودتر نمایش داد و برای بخشهای در حال دریافت داده، مرزهای انتظار تعریف کرد.
import { Suspense } from "react";
import ProductDetails from "./product-details";
import ProductReviews from "./product-reviews";
import RelatedProducts from "./related-products";
export default async function ProductPage() {
const product = await getProduct();
return (
<main>
<ProductDetails product={product} />
<Suspense fallback={<p>Loading reviews...</p>}>
<ProductReviews productId={product.id} />
</Suspense>
<Suspense fallback={<p>Loading recommendations...</p>}>
<RelatedProducts productId={product.id} />
</Suspense>
</main>
);
}
در این ساختار، بخشهای مستقل میتوانند با زمانبندی متفاوت آماده شوند.
البته برای استفاده مؤثر از Streaming باید به محل قرارگیری Suspense، وابستگی دادهها و قابلیتهای Framework توجه کرد. اضافه کردن Suspense بهتنهایی تضمینکننده بهبود عملکرد نیست.
در پروژههای React ممکن است با این دو عبارت روبهرو شوید:
"use client";
و:
"use server";
این دو Directive کار یکسانی انجام نمیدهند.
"use client" مرز Client Module Graph را مشخص میکند.
"use server" برای تعریف Server Function استفاده میشود؛ تابعی که از Client قابل فراخوانی است اما در سرور اجرا میشود.
برای مثال:
"use server";
import { db } from "@/lib/db";
export async function addToCart(productId: string) {
// Authenticate the user.
// Validate the product.
// Check permissions.
// Update the database.
await db.cartItem.create({
data: {
productId,
},
});
}
این تابع میتواند از یک Client Component فراخوانی شود.
اما نکته امنیتی مهمی وجود دارد: Server Function را نباید یک تابع داخلی و غیرقابلدسترسی تصور کرد. درخواستهای ورودی آن باید مانند ورودی یک Endpoint سمت سرور اعتبارسنجی و مجوزسنجی شوند.
همچنین "use server" یک Server Component ایجاد نمیکند.
RSC ابزار جادویی برای سریعتر کردن تمام پروژههای React نیست.
| مسئله | نقش RSC |
|---|
| ارسال کد غیرضروری به مرورگر | کمک به کاهش Client JavaScript |
| دریافت مستقیم داده در سرور | فراهم کردن امکان آن |
| دسترسی به Secretها | نگهداشتن منطق حساس در محیط سرور |
| نمایش اولیه محتوا | میتواند در کنار SSR استفاده شود |
| تعامل کاربر | همچنان به Client Components نیاز دارد |
| مدیریت State کلاینت | جایگزین ابزارهای State Management نیست |
| Real-time Data | به راهکارهای جداگانه نیاز دارد |
| Authorization | باید بهصورت صریح پیادهسازی شود |
یک Server Component میتواند حجم JavaScript سمت کلاینت را کاهش دهد، اما این موضوع به ساختار پروژه، وابستگیها و تعداد Client Components بستگی دارد.
همچنین انتقال پردازش به سرور میتواند هزینه شبکه، بار سرور یا تأخیر دریافت داده را افزایش دهد. پس باید عملکرد واقعی را اندازهگیری کرد.
در Next.js App Router، page.tsx و layout.tsx بهصورت پیشفرض Server Component هستند.
یک ساختار ساده:
app/
└── products/
├── page.tsx
└── components/
├── product-details.tsx
├── product-reviews.tsx
├── quantity-selector.tsx
└── add-to-cart-button.tsx
lib/
├── db.ts
└── products.ts
نمونه صفحه:
import { getProduct } from "@/lib/products";
import ProductDetails from "./components/product-details";
import QuantitySelector from "./components/quantity-selector";
import AddToCartButton from "./components/add-to-cart-button";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
return (
<main>
<ProductDetails product={product} />
<QuantitySelector />
<AddToCartButton productId={product.id} />
</main>
);
}
در این مثال:
- صفحه، Server Component است.
- اطلاعات محصول در سرور دریافت میشود.
- انتخاب تعداد، Client Component است.
- افزودن به سبد خرید، Client Component است.
- داده لازم برای تعامل بهصورت محدود به کلاینت منتقل میشود.
برای اطلاعات بیشتر درباره جزئیات پیادهسازی و رفتار نسخههای مختلف Next.js، مستندات همان نسخه را بررسی کنید؛ بهخصوص رفتار Cache و Data Fetching ممکن است با نسخه Framework تغییر کند.
قبل از اینکه یک فایل را با "use client" علامتگذاری کنید، این سؤالها را از خودتان بپرسید:
- آیا این کامپوننت واقعاً به State نیاز دارد؟
- آیا Event Handler دارد؟
- آیا از Browser API استفاده میکند؟
- آیا میتواند در سرور باقی بماند؟
- آیا فقط یک فرزند کوچک آن به تعامل نیاز دارد؟
- آیا Props ارسالی به Client واقعاً ضروری هستند؟
- آیا اطلاعات محرمانه یا داده اضافی در حال عبور از مرز هستند؟
- آیا این بخش باید با Streaming نمایش داده شود؟
اگر فقط یک بخش کوچک به تعامل نیاز دارد، بهتر است همان بخش را Client کنید، نه کل صفحه را.
React Server Components صرفاً یک روش جدید برای نوشتن کامپوننت نیستند؛ آنها روی معماری، Data Flow، مرزبندی کد و نحوه ارسال JavaScript به مرورگر تأثیر میگذارند.
برای استفاده درست از RSC باید بهجای پرسیدن اینکه «این کامپوننت Server است یا Client؟»، سؤال دقیقتری بپرسیم:
این بخش از برنامه به کدام محیط نیاز دارد و چه چیزی واقعاً باید از مرز شبکه عبور کند؟
این تغییر نگاه، پایه طراحی معماری مناسب در پروژههای مدرن React است.
این مقاله با تمرکز بر معماری React و پیادهسازی آن در Next.js App Router نوشته شده است.