React Server Components: النماذج الذهنية، والأنماط، والمزالق
الدليل العملي لمكوّنات React Server التي تمنيت وجوده عندما بدأت. النماذج الذهنية، والأنماط الواقعية، ومشكلة الحدود، والأخطاء التي ارتكبتها حتى لا تضطر أنت لذلك.
أمضيت الأشهر الثلاثة الأولى مع React Server Components وأنا أظن أنني أفهمها. قرأت مستندات RFC، وشاهدت محاضرات المؤتمرات، وبنيت بضعة تطبيقات تجريبية. كنت واثقاً من فهمي بشكل خاطئ في كل شيء تقريباً.
المشكلة ليست أن RSC معقّدة. المشكلة أن النموذج الذهني مختلف فعلاً عن أي شيء فعلناه في React من قبل، وكل شخص — بمن فيهم أنا — يحاول إدخالها في القالب القديم. "إنها مثل SSR." ليست كذلك. "إنها مثل PHP." أقرب، لكن لا. "إنها مجرد مكوّنات تعمل على الخادم." صحيح تقنياً، عديم الفائدة عملياً.
ما يلي هو كل ما احتجت فعلاً لمعرفته، مكتوباً بالطريقة التي تمنيت لو شرحها لي أحد. ليس النسخة النظرية. النسخة التي تحدّق فيها بخطأ تسلسل (serialization) في الساعة الحادية عشرة ليلاً وتحتاج أن تفهم لماذا.
النموذج الذهني الذي يعمل فعلاً#
انسَ كل ما تعرفه عن عملية التصيير (rendering) في React للحظة. إليك الصورة الجديدة.
في React التقليدية (من جهة العميل)، تُشحن شجرة المكوّنات بالكامل إلى المتصفح كـ JavaScript. يقوم المتصفح بتنزيلها، وتحليلها، وتنفيذها، وتصيير النتيجة. كل مكوّن — سواء كان نموذج تفاعلي من 200 سطر أو فقرة نصية ثابتة — يمر عبر نفس الأنبوب.
تقسم React Server Components هذا إلى عالمَين:
مكوّنات الخادم (Server Components) تعمل على الخادم. تُنفَّذ مرة واحدة، وتنتج مخرجاتها، وترسل النتيجة إلى العميل — وليس الكود. لا يرى المتصفح أبداً دالة المكوّن، ولا يُنزّل اعتمادياتها، ولا يعيد تصييرها.
مكوّنات العميل (Client Components) تعمل مثل React التقليدية. تُشحن إلى المتصفح، وتمر بعملية الإماهة (hydration)، وتحتفظ بالحالة، وتعالج الأحداث. إنها الـ React التي تعرفها بالفعل.
الفكرة الجوهرية التي استغرقتُ وقتاً محرجاً لاستيعابها: مكوّنات الخادم هي الخيار الافتراضي. في Next.js App Router، كل مكوّن هو مكوّن خادم ما لم تختر صراحةً جعله مكوّن عميل باستخدام "use client". هذا عكس ما اعتدنا عليه، ويغيّر طريقة تفكيرك في التركيب.
شلال التصيير#
إليك ما يحدث فعلاً عندما يطلب المستخدم صفحة:
1. Request hits the server
2. Server executes Server Components top-down
3. When a Server Component hits a "use client" boundary,
it stops — that subtree will render on the client
4. Server Components produce RSC Payload (a special format)
5. RSC Payload streams to the client
6. Client renders Client Components, stitching them into
the server-rendered tree
7. Hydration makes Client Components interactive
الخطوة 4 هي حيث يكمن معظم الالتباس. حمولة RSC ليست HTML. إنها تنسيق تدفّقي (streaming) خاص يصف شجرة المكوّنات — ما الذي صيّره الخادم، وأين يحتاج العميل لتولي الأمر، وما الخصائص (props) التي يجب تمريرها عبر الحد.
يبدو تقريباً هكذا (مبسّط):
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}]]}]
لست بحاجة لحفظ هذا التنسيق. لكن فهم وجوده — أن هناك طبقة تسلسل بين الخادم والعميل — سيوفر عليك ساعات من تصحيح الأخطاء. في كل مرة تحصل فيها على خطأ "Props must be serializable"، فذلك لأن شيئاً تمرّره لا يمكنه النجاة من هذه الترجمة.
ماذا يعني "يعمل على الخادم" فعلاً#
عندما أقول أن مكوّن الخادم "يعمل على الخادم"، أعني ذلك حرفياً. تُنفَّذ دالة المكوّن في Node.js (أو بيئة Edge runtime). هذا يعني أنه يمكنك:
// app/dashboard/page.tsx — this is a Server Component by default
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");
// Direct database query. No API route needed.
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>
);
}لا useEffect. لا إدارة حالة تحميل. لا مسار API لربط الأشياء ببعضها. المكوّن هو طبقة البيانات. هذا هو أكبر مكسب في RSC وهو الشيء الذي شعرت بعدم الارتياح تجاهه في البداية، لأنني كنت أفكر باستمرار "لكن أين الفصل؟"
الفصل هو حد "use client". كل ما فوقه خادم. كل ما تحته عميل. هذه هي بنيتك المعمارية.
حد الخادم/العميل#
هنا حيث ينهار فهم معظم الناس، وحيث أمضيت معظم وقت تصحيح الأخطاء في الأشهر القليلة الأولى.
توجيه "use client"#
يحدّد توجيه "use client" في أعلى الملف كل ما يُصدَّر من ذلك الملف كمكوّن عميل. إنه تعليق على مستوى الوحدة (module-level)، وليس على مستوى المكوّن.
// src/components/Counter.tsx
"use client";
import { useState } from "react";
// This entire file is now "client territory"
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>
);
}
// This is ALSO a Client Component because it's in the same file
export function ResetButton({ onReset }: { onReset: () => void }) {
return <button onClick={onReset}>Reset</button>;
}خطأ شائع: وضع "use client" في ملف تجميع (barrel file) مثل index.ts يعيد تصدير كل شيء. تهانينا، لقد جعلت مكتبة مكوّناتك بالكامل تعمل من جهة العميل. رأيت فرقاً تشحن 200 كيلوبايت من JavaScript بهذه الطريقة عن طريق الخطأ.
ما الذي يعبر الحد#
إليك القاعدة التي ستنقذك: كل شيء يعبر حد الخادم-العميل يجب أن يكون قابلاً للتسلسل إلى JSON.
ما هو قابل للتسلسل:
- السلاسل النصية، الأرقام، القيم المنطقية، null، undefined
- المصفوفات والكائنات البسيطة (التي تحتوي على قيم قابلة للتسلسل)
- التواريخ (تُسلسل كسلاسل ISO)
- مكوّنات الخادم (كـ JSX — سنصل لهذا)
- FormData
- المصفوفات المُنمَّطة (Typed arrays)، ArrayBuffer
ما هو غير قابل للتسلسل:
- الدوال (بما في ذلك معالجات الأحداث)
- الأصناف (نسخ الأصناف المخصصة)
- الرموز (Symbols)
- عقد DOM
- التدفقات (Streams) (في معظم السياقات)
هذا يعني أنه لا يمكنك فعل هذا:
// 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}
// ERROR: Functions are not serializable
onItemClick={(id) => console.log(id)}
// ERROR: Class instances are not serializable
formatter={new Intl.NumberFormat("en-US")}
/>
);
}الحل ليس جعل الصفحة مكوّن عميل. الحل هو دفع التفاعلية للأسفل وجلب البيانات للأعلى:
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
export default async function Page() {
const items = await getItems();
// Only pass serializable data
return <ItemList items={items} locale="en-US" />;
}// 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);
// Create the formatter on the client side
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>
);
}مفهوم "الجزيرة" الخاطئ#
ظننت في البداية أن مكوّنات العميل هي "جزر" — أجزاء تفاعلية صغيرة في بحر من المحتوى المُصيَّر على الخادم. هذا صحيح جزئياً لكنه يغفل تفصيلاً حاسماً: يمكن لمكوّن العميل تصيير مكوّنات الخادم إذا مُرِّرت كأبناء (children) أو خصائص (props).
هذا يعني أن الحد ليس جداراً صلباً. إنه أشبه بغشاء. يمكن للمحتوى المُصيَّر على الخادم أن يتدفق عبر مكوّنات العميل من خلال نمط الأبناء. سنتعمق في هذا في قسم التركيب.
أنماط جلب البيانات#
تغيّر RSC جلب البيانات جذرياً. لا مزيد من useEffect + useState + حالات التحميل للبيانات المعروفة وقت التصيير. لكن للأنماط الجديدة مزالقها الخاصة.
الجلب الأساسي مع التخزين المؤقت#
في مكوّن الخادم، تستخدم fetch مباشرة. يوسّع Next.js الدالة العامة fetch لإضافة التخزين المؤقت:
// app/products/page.tsx
export default async function ProductsPage() {
// Cached by default — same URL returns cached result
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>
);
}تحكّم في سلوك التخزين المؤقت بشكل صريح:
// Revalidate every 60 seconds (ISR-like behavior)
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
// No caching — always fresh data
const res = await fetch("https://api.example.com/user/profile", {
cache: "no-store",
});
// Cache with tags for on-demand revalidation
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});ثم يمكنك إعادة التحقق بالوسم من Server Action:
"use server";
import { revalidateTag } from "next/cache";
export async function refreshProducts() {
revalidateTag("products");
}جلب البيانات المتوازي#
أكثر خطأ أداء شائع أراه: جلب البيانات بشكل تتابعي عندما يمكن للمتوازي أن يعمل بشكل جيد.
سيئ — تتابعي (شلالات):
// app/dashboard/page.tsx — DON'T DO THIS
export default async function Dashboard() {
const user = await getUser(); // 200ms
const orders = await getOrders(); // 300ms
const notifications = await getNotifications(); // 150ms
// Total: 650ms — each waits for the previous one
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}جيد — متوازي:
// app/dashboard/page.tsx — DO THIS
export default async function Dashboard() {
// All three fire simultaneously
const [user, orders, notifications] = await Promise.all([
getUser(), // 200ms
getOrders(), // 300ms (runs in parallel)
getNotifications(), // 150ms (runs in parallel)
]);
// Total: ~300ms — limited by the slowest request
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}أفضل من ذلك — متوازي مع حدود Suspense مستقلة:
// app/dashboard/page.tsx — BEST
import { Suspense } from "react";
export default function Dashboard() {
// Note: this component is NOT async — it delegates to children
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<OrderListSkeleton />}>
<OrderList />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<Notifications />
</Suspense>
</div>
);
}
// Each component fetches its own data
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>;
}هذا النمط الأخير هو الأقوى لأن كل قسم يُحمَّل بشكل مستقل. يرى المستخدم المحتوى فور توفره، وليس الكل-أو-لا-شيء. الأقسام السريعة لا تنتظر البطيئة.
إزالة تكرار الطلبات#
يُزيل Next.js تلقائياً التكرار من استدعاءات fetch التي لها نفس العنوان URL ونفس الخيارات أثناء دورة تصيير واحدة. هذا يعني أنك لست بحاجة لرفع جلب البيانات لتجنب الطلبات المكررة:
// Both of these components can fetch the same URL
// and Next.js will only make ONE actual HTTP request
async function Header() {
const user = await fetch("/api/user").then(r => r.json());
return <nav>Welcome, {user.name}</nav>;
}
async function Sidebar() {
// Same URL — automatically deduped, not a second request
const user = await fetch("/api/user").then(r => r.json());
return <aside>Role: {user.role}</aside>;
}تحذير مهم: هذا يعمل فقط مع fetch. إذا كنت تستخدم ORM أو عميل قاعدة بيانات مباشرة، تحتاج لاستخدام دالة cache() من React:
import { cache } from "react";
import { db } from "@/lib/database";
// Wrap your data function with cache()
// Now multiple calls in the same render = one actual query
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
});تُزيل cache() التكرار طوال عمر طلب خادم واحد. إنها ليست ذاكرة مؤقتة دائمة — إنها حفظ مؤقت لكل طلب (per-request memoization). بعد انتهاء الطلب، تُجمع القيم المخزّنة مؤقتاً كنفايات.
أنماط تركيب المكوّنات#
هنا حيث تصبح RSC أنيقة فعلاً، بمجرد أن تفهم الأنماط. ومربكة فعلاً، حتى تفهمها.
نمط "الأبناء كثقب"#
هذا أهم نمط تركيب في RSC واستغرق مني أسابيع لتقديره بالكامل. المشكلة: لديك مكوّن عميل يوفر تخطيطاً أو تفاعلية ما، وتريد تصيير مكوّنات خادم بداخله.
لا يمكنك استيراد مكوّن خادم في ملف مكوّن عميل. لحظة إضافتك "use client"، كل شيء في تلك الوحدة يصبح من جهة العميل. لكن يمكنك تمرير مكوّنات الخادم كـ children:
// 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">
{/* These children can be Server Components! */}
{children}
</div>
)}
</aside>
);
}// 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>
{/* These are Server Components, passed through a Client Component */}
<UserProfile />
<NavigationLinks />
</Sidebar>
<main>{children}</main>
</div>
);
}لماذا يعمل هذا؟ لأن مكوّنات الخادم (UserProfile، NavigationLinks) تُصيَّر على الخادم أولاً، ثم يُمرَّر ناتجها (حمولة RSC) كـ children إلى مكوّن العميل. لا يحتاج مكوّن العميل لمعرفة أنها كانت مكوّنات خادم — إنه يستقبل فقط عقد React مُصيَّرة مسبقاً.
فكّر في children على أنها "ثقب" في مكوّن العميل يمكن للمحتوى المُصيَّر على الخادم أن يتدفق من خلاله.
تمرير مكوّنات الخادم كخصائص#
يتعمّم نمط الأبناء ليشمل أي خاصية تقبل React.ReactNode:
// 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>
);
}// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings"; // Server Component — can fetch data
import { BillingSettings } from "./BillingSettings"; // Server Component — can fetch data
import { SecuritySettings } from "./SecuritySettings"; // Server Component — can fetch data
export default function SettingsPage() {
return (
<TabLayout
tabs={[
{ label: "Profile", content: <ProfileSettings /> },
{ label: "Billing", content: <BillingSettings /> },
{ label: "Security", content: <SecuritySettings /> },
]}
/>
);
}يمكن لكل مكوّن إعدادات أن يكون مكوّن خادم غير متزامن (async) يجلب بياناته الخاصة. مكوّن العميل (TabLayout) يتعامل فقط مع تبديل التبويبات. هذا نمط قوي للغاية.
مكوّنات الخادم غير المتزامنة#
يمكن أن تكون مكوّنات الخادم async. هذا أمر كبير لأنه يعني أن جلب البيانات يحدث أثناء التصيير، وليس كتأثير جانبي:
// This is valid and beautiful
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>
);
}مكوّنات العميل لا يمكن أن تكون async. إذا حاولت جعل مكوّن "use client" غير متزامن، سيرمي React خطأ. هذا قيد صارم.
حدود Suspense: البدائية التدفّقية#
Suspense هي الطريقة التي تحصل بها على التدفق (streaming) في RSC. بدون حدود Suspense، تنتظر الصفحة بأكملها أبطأ مكوّن غير متزامن. معها، كل قسم يتدفق بشكل مستقل:
// 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>
{/* Static — renders immediately */}
<HeroSection />
{/* Fast data — shows quickly */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
{/* Medium speed — shows when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewCarousel />
</Suspense>
{/* Slow (ML-powered) — shows last, doesn't block the rest */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendationEngine />
</Suspense>
</main>
);
}يرى المستخدم HeroSection فوراً، ثم يتدفق ProductGrid، ثم المراجعات، ثم التوصيات. كل حد Suspense هو نقطة تدفق مستقلة.
تداخل حدود Suspense صالح ومفيد أيضاً:
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</Dashboard>
</Suspense>إذا كان Dashboard سريعاً لكن RevenueChart بطيئاً، يُحل حد Suspense الخارجي أولاً (يعرض هيكل لوحة المعلومات)، وحد Suspense الداخلي للرسم البياني يُحل لاحقاً.
حدود الأخطاء مع Suspense#
اقرن Suspense مع error.tsx لواجهات مستخدم مرنة:
// app/dashboard/error.tsx — Client Component (must be)
"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>
);
}يلف ملف error.tsx تلقائياً جزء المسار المقابل في React Error Boundary. إذا رمى أي مكوّن خادم في ذلك الجزء خطأ، تظهر واجهة الخطأ بدلاً من تعطّل الصفحة بالكامل.
متى تستخدم أيهما: شجرة القرار#
بعد بناء عدة تطبيقات إنتاجية مع RSC، استقررت على إطار قرار واضح. إليك عملية التفكير الفعلية التي أمر بها لكل مكوّن:
ابدأ بمكوّنات الخادم (الخيار الافتراضي)#
يجب أن يكون كل مكوّن مكوّن خادم ما لم يكن هناك سبب محدد يمنع ذلك. هذه هي القاعدة الأهم على الإطلاق.
اجعله مكوّن عميل عندما:#
1. يستخدم واجهات برمجة خاصة بالمتصفح
"use client";
// window, document, navigator, localStorage, etc.
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. يستخدم خطافات React التي تتطلب حالة أو تأثيرات
useState، useEffect، useReducer، useRef (للمراجع المتغيّرة)، useContext — أي من هذه يتطلب "use client".
"use client";
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// This component MUST be a Client Component because it
// uses useState and manages user input
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}3. يربط معالجات أحداث
onClick، onChange، onSubmit، onMouseEnter — أي سلوك تفاعلي يعني أنه من جهة العميل.
4. يستخدم مكتبات من جهة العميل
Framer Motion، React Hook Form، Zustand، React Query (لجلب البيانات من جهة العميل)، أي مكتبة رسوم بيانية تُصيَّر على canvas أو SVG تفاعلي.
أبقِه كمكوّن خادم عندما:#
- يعرض بيانات فقط (بدون تفاعل مستخدم)
- يجلب بيانات من قاعدة بيانات أو واجهة برمجة
- يصل إلى موارد الواجهة الخلفية (نظام الملفات، متغيرات البيئة التي تحتوي أسرار)
- يستورد اعتماديات كبيرة لا يحتاجها العميل (محللات Markdown، مُلوّنات الصياغة، مكتبات التاريخ للتنسيق)
- يُصيّر محتوى ثابتاً أو شبه ثابت
القرار الواقعي في الممارسة العملية#
إليك مثالاً ملموساً. أبني صفحة منتج:
ProductPage (Server)
├── ProductBreadcrumbs (Server) — static navigation, no interactivity
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│ ├── ProductTitle (Server) — just text
│ ├── ProductPrice (Server) — formatted number, no interaction
│ └── AddToCartButton (Client) — onClick, manages cart state
├── ProductDescription (Server) — rendered markdown
├── Suspense
│ └── RelatedProducts (Server) — async data fetch, slow API
└── Suspense
└── ProductReviews (Server)
└── ReviewForm (Client) — form with validation
لاحظ النمط: هيكل الصفحة والأجزاء الغنية بالبيانات هي مكوّنات خادم. الجزر التفاعلية (ImageGallery، AddToCartButton، ReviewForm) هي مكوّنات عميل. الأقسام البطيئة (RelatedProducts، ProductReviews) ملفوفة في Suspense.
هذا ليس نظرياً. هكذا تبدو أشجار مكوّناتي فعلاً.
الأخطاء الشائعة (ارتكبتها كلها)#
الخطأ 1: جعل كل شيء مكوّن عميل#
الطريق الأسهل عند الانتقال من Pages Router أو Create React App هو وضع "use client" على كل شيء. إنه يعمل! لا شيء ينكسر! لكنك أيضاً تشحن شجرة مكوّناتك بالكامل كـ JavaScript ولا تحصل على أي فائدة من RSC.
رأيت قواعد أكواد حيث يحتوي التخطيط الجذري على "use client". في تلك النقطة أنت حرفياً تشغّل تطبيق React من جهة العميل مع خطوات إضافية.
الحل: ابدأ بمكوّنات الخادم. أضف "use client" فقط عندما يخبرك المُترجم بذلك (لأنك استخدمت خطافاً أو معالج أحداث). ادفع "use client" إلى أقصى حد ممكن أسفل الشجرة.
الخطأ 2: تمرير الخصائص عبر الحد بشكل متكرر#
// BAD: fetching data in a Server Component, then passing it through
// multiple 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>
);
}كل قطعة بيانات تمرّرها عبر الحد تُسلسل في حمولة RSC. مرّر نفس الكائن خمس مرات؟ إنه في الحمولة خمس مرات. رأيت حمولات RSC تتضخم إلى ميغابايتات بسبب هذا.
الحل: استخدم التركيب. مرّر مكوّنات الخادم كأبناء بدلاً من تمرير البيانات كخصائص:
// GOOD: Server Components fetch their own data, pass through as children
// app/page.tsx (Server)
export default function Page() {
return (
<ClientShell>
<UserInfo /> {/* Server Component — fetches its own data */}
<Settings /> {/* Server Component — fetches its own data */}
<ClientWidget>
<UserAvatar /> {/* Server Component — fetches its own data */}
</ClientWidget>
</ClientShell>
);
}الخطأ 3: عدم استخدام Suspense#
بدون Suspense، فإن زمن استجابة الصفحة الأول (TTFB) محدود بأبطأ عملية جلب بيانات. كان لدي صفحة لوحة معلومات تستغرق 4 ثوانٍ للتحميل لأن استعلام تحليلات واحد كان بطيئاً، رغم أن بقية بيانات الصفحة كانت جاهزة في 200 مللي ثانية.
// BAD: everything waits for everything
export default async function Dashboard() {
const stats = await getStats(); // 200ms
const chart = await getChartData(); // 300ms
const analytics = await getAnalytics(); // 4000ms ← blocks everything
return (
<div>
<Stats data={stats} />
<Chart data={chart} />
<Analytics data={analytics} />
</div>
);
}// GOOD: analytics loads independently
export default function Dashboard() {
return (
<div>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* Takes 4s but doesn't block the rest */}
</Suspense>
</div>
);
}الخطأ 4: أخطاء التسلسل وقت التشغيل#
هذا مؤلم بشكل خاص لأنك غالباً لا تكتشفه حتى بيئة الإنتاج. تمرّر شيئاً غير قابل للتسلسل عبر الحد وتحصل على خطأ مبهم:
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.
المتسببون الشائعون:
- تمرير كائنات Date (استخدم
.toISOString()بدلاً من ذلك) - تمرير Map أو Set (حوّلها إلى مصفوفات/كائنات)
- تمرير نسخ أصناف من ORMs (استخدم
.toJSON()أو انشرها في كائنات بسيطة) - تمرير دوال (انقل المنطق إلى مكوّن العميل أو استخدم Server Actions)
- تمرير نتائج نماذج Prisma مع حقول
Decimal(حوّلها إلىnumberأوstring)
// BAD
const user = await prisma.user.findUnique({ where: { id } });
// user might have non-serializable fields (Decimal, BigInt, etc.)
return <ClientProfile user={user} />;
// GOOD
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} />;الخطأ 5: استخدام Context لكل شيء#
useContext يعمل فقط في مكوّنات العميل. إذا حاولت استخدام سياق (context) React في مكوّن خادم، لن يعمل. رأيت أشخاصاً يجعلون تطبيقهم بالكامل مكوّن عميل فقط لاستخدام سياق المظهر (theme).
الحل: للمظاهر وغيرها من الحالة العامة، استخدم متغيرات CSS المُعيَّنة من جهة الخادم، أو استخدم دوال cookies() / headers():
// 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>
);
}للحالة التي تكون فعلاً من جهة العميل (رموز المصادقة، سلال التسوق، البيانات اللحظية)، أنشئ مكوّن عميل موفّر (provider) رفيع على المستوى المناسب — وليس الجذر:
// 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;
}// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
// CartProvider is a Client Component, but children flow through as server content
return <CartProvider>{children}</CartProvider>;
}الخطأ 6: تجاهل تأثير حجم الحزمة#
أحد أكبر مكاسب RSC هو أن كود مكوّنات الخادم لا يُشحن أبداً إلى العميل. لكنك تحتاج للتفكير في هذا بنشاط. إذا كان لديك مكوّن يستخدم محلل Markdown بحجم 50 كيلوبايت ويعرض فقط محتوى مُصيَّر — يجب أن يكون مكوّن خادم. يبقى المحلل على الخادم، ويذهب فقط ناتج HTML إلى العميل.
// Server Component — marked stays on the server
import { marked } from "marked"; // 50KB library — never ships to client
async function BlogContent({ slug }: { slug: string }) {
const post = await getPost(slug);
const html = marked(post.markdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}لو جعلت هذا مكوّن عميل، لشُحنت marked إلى المتصفح. من أجل لا شيء. سيُنزّل المستخدم 50 كيلوبايت من JavaScript فقط لتصيير محتوى كان يمكن أن يكون HTML من البداية.
افحص حزمتك باستخدام @next/bundle-analyzer. النتائج قد تفاجئك.
استراتيجية التخزين المؤقت#
تبسّط التخزين المؤقت في Next.js 15+ بشكل كبير مقارنة بالإصدارات السابقة، لكن لا تزال هناك طبقات متميزة يجب فهمها.
دالة cache() (React)#
دالة cache() من React هي لإزالة التكرار لكل طلب، وليست تخزيناً مؤقتاً دائماً:
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 } });
});
// Call this anywhere in your component tree during a single request.
// Only one actual database query will execute.هذا محدود النطاق بطلب خادم واحد. عندما ينتهي الطلب، تختفي القيمة المخزّنة مؤقتاً. إنه حفظ مؤقت (memoization)، وليس تخزيناً مؤقتاً.
unstable_cache (Next.js)#
للتخزين المؤقت الدائم عبر الطلبات، استخدم unstable_cache (الاسم كان "unstable" للأبد، لكنه يعمل بشكل جيد في الإنتاج):
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"], // cache key prefix
{
revalidate: 3600, // revalidate every hour
tags: ["products"], // for on-demand revalidation
}
);
// Usage in a Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
const products = await getCachedProducts(categoryId);
return <Grid items={products} />;
}لإبطال الصلاحية:
"use server";
import { revalidateTag } from "next/cache";
export async function onProductUpdate() {
revalidateTag("products");
}التصيير الثابت مقابل الديناميكي#
يقرر Next.js ما إذا كان المسار ثابتاً أو ديناميكياً بناءً على ما تستخدمه فيه:
ثابت (يُصيَّر وقت البناء، يُخزَّن مؤقتاً):
- لا دوال ديناميكية (
cookies()،headers()،searchParams) - جميع استدعاءات
fetchمع تمكين التخزين المؤقت - لا
export const dynamic = "force-dynamic"
ديناميكي (يُصيَّر لكل طلب):
- يستخدم
cookies()،headers()، أوsearchParams - يستخدم
fetchمعcache: "no-store" - لديه
export const dynamic = "force-dynamic" - يستخدم
connection()أوafter()منnext/server
يمكنك التحقق من أي المسارات ثابت مقابل ديناميكي بتشغيل next build — يعرض دليلاً في الأسفل:
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
التسلسل الهرمي للتخزين المؤقت#
فكّر في التخزين المؤقت كطبقات:
1. React cache() — per-request, in-memory, automatic dedup
2. fetch() cache — cross-request, automatic for GET requests
3. unstable_cache() — cross-request, for non-fetch operations
4. Full Route Cache — rendered HTML cached at build/revalidation time
5. Router Cache (client) — in-browser cache of visited routes
كل طبقة تخدم غرضاً مختلفاً. لا تحتاج دائماً لكلها، لكن فهم أيها نشط يساعد في تصحيح مشكلات "لماذا لا تتحدث بياناتي؟".
استراتيجية تخزين مؤقت واقعية#
إليك ما أفعله فعلاً في الإنتاج:
// lib/data/products.ts
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
// Per-request dedup: call this multiple times in one render,
// only one DB query runs
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { category: true, images: true },
});
});
// Cross-request cache: results persist across requests,
// revalidate every 5 minutes or on-demand via 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"] }
);
// No caching: always fresh (for user-specific data)
export const getUserCart = cache(async (userId: string) => {
// cache() here is only for per-request dedup, not persistence
return db.cart.findUnique({
where: { userId },
include: { items: { include: { product: true } } },
});
});القاعدة العامة: البيانات العامة التي تتغير نادراً تحصل على unstable_cache. البيانات الخاصة بالمستخدم تحصل على cache() لإزالة التكرار فقط. البيانات اللحظية لا تحصل على تخزين مؤقت على الإطلاق.
Server Actions: الجسر العائد#
تستحق Server Actions قسمها الخاص لأنها تُكمل قصة RSC. إنها الطريقة التي تتواصل بها مكوّنات العميل مع الخادم بدون مسارات API.
// 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" };
}
}// 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 هي الجواب على "كيف أغيّر البيانات؟" في عالم RSC. إنها تحل محل معظم مسارات API لإرسال النماذج، والتعديلات، والتأثيرات الجانبية.
القواعد الأساسية لـ Server Actions:
- تحقق دائماً من المدخلات (الدالة قابلة للاستدعاء من العميل — تعامل معها كنقطة نهاية API)
- أعِد دائماً بيانات قابلة للتسلسل
- تعمل على الخادم، لذا يمكنك الوصول إلى قواعد البيانات، وأنظمة الملفات، والأسرار
- يمكنها استدعاء
revalidatePath()أوrevalidateTag()لتحديث البيانات المخزّنة مؤقتاً بعد التعديلات
أنماط الانتقال#
إذا كان لديك تطبيق React موجود (Pages Router، Create React App، Vite)، فالانتقال إلى RSC لا يجب أن يكون إعادة كتابة. إليك كيف أتعامل معه.
الخطوة 1: رسم خريطة مكوّناتك#
راجع شجرة مكوّناتك وصنّف كل شيء:
Component State? Effects? Events? → Decision
─────────────────────────────────────────────────────────
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) or split
UserAvatar No No No → Server
CommentForm Yes Yes Yes → Client
Sidebar Yes No Yes → Client (collapse toggle)
MarkdownRenderer No No No → Server (big dependency win)
DataTable Yes Yes Yes → Client (sorting, filtering)
الخطوة 2: انقل جلب البيانات للأعلى#
أكبر تغيير معماري هو نقل جلب البيانات من useEffect في المكوّنات إلى مكوّنات خادم async. هنا يكمن الجهد الحقيقي للانتقال.
قبل:
// Old pattern — data fetching in a 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>
);
}بعد:
// New pattern — Server Component fetches, Client Component interacts
// 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>
);
}لا إدارة حالة تحميل. لا حالة خطأ. لا useEffect. يتعامل الإطار مع كل ذلك من خلال Suspense وحدود الأخطاء.
الخطوة 3: قسّم المكوّنات عند حدود التفاعل#
العديد من المكوّنات غالبيتها ثابتة مع جزء تفاعلي صغير. قسّمها:
قبل (مكوّن عميل كبير واحد):
"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>
);
}بعد (مكوّن خادم مع جزيرة عميل صغيرة):
// 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>
);
}// 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>
);
}الصورة، والعنوان، والوصف، والسعر أصبحت الآن مُصيَّرة على الخادم. فقط زر المفضلة الصغير هو مكوّن عميل. JavaScript أقل، تحميل صفحات أسرع.
الخطوة 4: حوّل مسارات API إلى Server Actions#
إذا كانت لديك مسارات API موجودة فقط لخدمة واجهتك الأمامية (وليس مستهلكين خارجيين)، معظمها يمكن أن تصبح Server Actions:
قبل:
// app/api/contact/route.ts
export async function POST(request: Request) {
const body = await request.json();
// validate, save to DB, send email
return Response.json({ success: true });
}
// Client component
const response = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(formData),
});بعد:
// app/actions/contact.ts
"use server";
export async function submitContactForm(formData: FormData) {
// validate, save to DB, send email
return { success: true };
}
// Client component — just call the function directly
import { submitContactForm } from "@/app/actions/contact";أبقِ مسارات API لـ: webhooks، مستهلكي API الخارجيين، أي شيء يحتاج ترويسات HTTP مخصصة أو رموز حالة، رفع الملفات مع التدفق.
الخطوة 5: اختبار مكوّنات RSC#
يتطلب اختبار مكوّنات الخادم نهجاً مختلفاً قليلاً لأنها يمكن أن تكون غير متزامنة:
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
// Mock the 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 are async — await the 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();
});
});الفرق الأساسي: تنتظر (await) دالة المكوّن لأنها غير متزامنة. ثم تُصيّر JSX الناتج. كل شيء آخر يعمل كما في React Testing Library التقليدية.
هيكل الملفات لمشاريع RSC#
إليك الهيكل الذي استقررت عليه بعد عدة مشاريع. إنه رأي شخصي، لكنه يعمل:
src/
├── app/
│ ├── layout.tsx ← Root layout (Server Component)
│ ├── page.tsx ← Home page (Server Component)
│ ├── (marketing)/ ← Route group for marketing pages
│ │ ├── about/page.tsx
│ │ └── pricing/page.tsx
│ ├── (app)/ ← Route group for authenticated app
│ │ ├── layout.tsx ← App shell with auth check
│ │ ├── dashboard/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx ← Suspense fallback for this route
│ │ │ └── error.tsx ← Error boundary for this route
│ │ └── settings/
│ │ └── page.tsx
│ └── actions/ ← Server Actions
│ ├── auth.ts
│ └── products.ts
├── components/
│ ├── ui/ ← Shared UI primitives (mostly Client)
│ │ ├── Button.tsx ← "use client"
│ │ ├── Dialog.tsx ← "use client"
│ │ └── Card.tsx ← Server Component (just styling)
│ └── features/ ← Feature-specific components
│ ├── products/
│ │ ├── ProductGrid.tsx ← Server (async, fetches data)
│ │ ├── ProductCard.tsx ← Server (presentational)
│ │ ├── ProductSearch.tsx ← Client (useState, input)
│ │ └── AddToCart.tsx ← Client (onClick, mutation)
│ └── blog/
│ ├── PostList.tsx ← Server (async, fetches data)
│ ├── PostContent.tsx ← Server (markdown rendering)
│ └── CommentSection.tsx ← Client (form, real-time)
├── lib/
│ ├── data/ ← Data access layer
│ │ ├── products.ts ← cache() wrapped DB queries
│ │ └── users.ts
│ ├── database.ts
│ └── utils.ts
└── providers/
├── ThemeProvider.tsx ← "use client" — wraps parts that need theme
└── CartProvider.tsx ← "use client" — wraps shop section only
المبادئ الأساسية:
- مكوّنات الخادم ليس لها توجيه — إنها الخيار الافتراضي
- مكوّنات العميل مُعلَّمة صراحة — يمكنك معرفتها بنظرة واحدة
- جلب البيانات يعيش في
lib/data/— ملفوف بـcache()أوunstable_cache - Server Actions تعيش في
app/actions/— مُوضعة مع التطبيق، مفصولة بوضوح - الموفّرون (Providers) يلفون الحد الأدنى الضروري — وليس التطبيق بالكامل
الخلاصة#
React Server Components ليست مجرد واجهة برمجة جديدة. إنها طريقة مختلفة للتفكير في أين يعمل الكود، وأين تعيش البيانات، وكيف تتصل القطع. التحول في النموذج الذهني حقيقي ويستغرق وقتاً.
لكن بمجرد أن يتضح — بمجرد أن تتوقف عن مقاومة الحد وتبدأ بالتصميم حوله — ينتهي بك الأمر بتطبيقات أسرع، وأبسط، وأسهل صيانة مما كنا نبنيه من قبل. JavaScript أقل يُشحن إلى العميل. جلب البيانات لا يتطلب احتفالات. شجرة المكوّنات تصبح البنية المعمارية.
التحول يستحق العناء. فقط اعلم أن المشاريع القليلة الأولى ستشعر بعدم الارتياح، وهذا طبيعي. أنت لا تعاني لأن RSC سيئة. أنت تعاني لأنها جديدة فعلاً.
ابدأ بمكوّنات الخادم في كل مكان. ادفع "use client" إلى الأوراق. لُف الأشياء البطيئة في Suspense. اجلب البيانات حيث تُصيَّر. ركّب من خلال الأبناء.
هذا هو الدليل الكامل. كل شيء آخر تفاصيل.