السلسلة: هذا الدرس جزء من سلسلة React 19 وNext.js 15. للحصول على نظرة شاملة، اقرأ المقال الرئيسي.
المفهوم الأكثر إرباكاً لمن يكتشف React 19 وApp Router لـ Next.js هو الحدّ Server / Client. كل مكوّن يُعرَض افتراضياً جانب الخادم، بلا JavaScript مُرسَل للمتصفّح. ليصبح تفاعلياً، يجب أن يعبر الحدّ صراحة عبر التوجيه "use client". سيئ الوضع، هذا الحدّ يُضخّم حزمة JS أو يكسر التفاعلية بأخطاء غامضة. حسن الوضع، يعطي صفحات تُحمَّل ببضعة كيلوبايتات JS لتجارب غنية جداً.
هذا الدرس يتبع بناء صفحة منتج e-commerce — حالة ملموسة تخلط العرض الثابت، التفاعلية المحلية، واستدعاءات الخادم.
المتطلبات
- Node.js 20 LTS أو أحدث (Bun 1.2+ مقبول)
- معرفة React الأساسية (hooks، JSX)
- مشروع Next.js 15 مهيّأ مع App Router مفعّل
- المستوى: متوسط
- الوقت: 60-90 دقيقة
pnpm create next-app@latest produit-demo \
--typescript --tailwind --app --turbopack --src-dir --no-linter
الخطوة 1 — فهم القاعدة الافتراضية
في مشروع App Router، كل ملف في app/ يُعالَج كـ Server Component، إلا في حالة معاكسة. هذا يشمل page.tsx، layout.tsx، وكل المكوّنات المستوردة من هذه الملفات — إلا إن كان المكوّن المستورَد نفسه يحمل "use client".
Server Component لا يستطيع استخدام: useState، useEffect، useContext، useRef، ولا أي معالج حدث DOM (onClick، onChange، onSubmit). بالمقابل يستطيع await مباشرة، الوصول إلى نظام الملفات، فتح اتصالات قاعدة بيانات، قراءة متغيّرات بيئة سرية.
// src/app/produits/[id]/page.tsx
type Produit = { id: string; nom: string; prix: number; description: string };
async function getProduit(id: string): Promise<Produit> {
await new Promise((r) => setTimeout(r, 200));
return {
id,
nom: 'Casque audio Bluetooth Aria X',
prix: 89.90,
description: 'Autonomie 35 h, reduction de bruit active, USB-C.',
};
}
export default async function PageProduit({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const produit = await getProduit(id);
return (
<article className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold">{produit.nom}</h1>
<p className="text-lg mt-2">{produit.prix.toFixed(2)} €</p>
<p className="mt-4 text-gray-700">{produit.description}</p>
</article>
);
}
زر http://localhost:3000/produits/123. الصفحة تُعرَض بعد 200 مللي ثانية. في DevTools Network، الـ HTML يصل ممتلئاً بالاسم، السعر، الوصف. لا JavaScript خاص بهذه الصفحة حُمِّل. لاحظ: params أصبح Promise في Next.js 15 — منه await params. المكوّن نفسه async — ممكن جانب الخادم فقط.
الخطوة 2 — إضافة زر تفاعلي بدون كسر الخادم
صفحتنا تحتاج زر «أضف إلى السلّة». الإغراء الطبيعي: إضافة useState وonClick في page.tsx. فكرة سيئة — يحوّل كل الصفحة إلى Client Component، حتى المكوّن الذي يحمّل المنتج من القاعدة سيهاجر.
النهج الصحيح عزل المنطقة التفاعلية في مكوّن ابن موسوم "use client". هذا نمط client islands.
// src/components/AjouterAuPanier.tsx
'use client';
import { useState } from 'react';
export function AjouterAuPanier({ produitId }: { produitId: string }) {
const [quantite, setQuantite] = useState(1);
const [ajoute, setAjoute] = useState(false);
const handleClick = () => {
console.log('Ajout produit', produitId, 'x', quantite);
setAjoute(true);
setTimeout(() => setAjoute(false), 2000);
};
return (
<div className="flex items-center gap-3 mt-6">
<input type="number" min={1} value={quantite}
onChange={(e) => setQuantite(Number(e.target.value))}
className="border rounded px-2 py-1 w-20" />
<button onClick={handleClick}
className="bg-black text-white px-4 py-2 rounded">
{ajoute ? 'Ajoute' : 'Ajouter au panier'}
</button>
</div>
);
}
نستخدمه في الصفحة الخادمية بلا تغيير آخر. أعد تحميل الصفحة. التفاعلية تشتغل. في Network، ترى الآن chunk JS صغيراً — فقط كود AjouterAuPanier. PageProduit يبقى خادمياً.
الخطوة 3 — فخ props غير القابلة للتسلسل
عند تمرير props من Server إلى Client، Next.js يسلسلها في JSON. هذا يفرض قيداً: لا يمكن تمرير إلا قيم قابلة للتسلسل.
مسموح: سلاسل، أرقام، booleans، null، undefined، مصفوفات وكائنات بسيطة، إضافة لـ Promises قابلة للحلّ وReactElements. ممنوع: دوال، نسخ classes، Dates خام، Map، Set، fonctions fléchées التي تلتقط سياق خادم.
// لا يعمل
const formaterPrix = (n: number) => n.toFixed(2) + ' €';
return <AjouterAuPanier produitId={produit.id} formater={formaterPrix} />;
// Error: Functions cannot be passed directly to Client Components.
الحلّ: إما استدعاء الدالة على جانب الخادم وتمرير النتيجة (سلسلة)، أو تعريف الدالة في ملف client واستيرادها من AjouterAuPanier. للتواريخ، مرّر ISO string عبر date.toISOString() ثم new Date(str) جانب العميل. للـ Map/Set، حوّل إلى مصفوفة entries.
الخطوة 4 — تركيب Server وClient في شجرة عميقة
سؤال متكرر: هل يمكن تضمين Server Component داخل Client Component؟ الجواب القصير: لا بـ import مباشر، نعم بالتركيب عبر children. نمط قوي يفكّ حالات استخدام عديدة.
// src/components/Onglets.tsx
'use client';
import { useState, ReactNode } from 'react';
export function Onglets({
description,
avis,
}: {
description: ReactNode;
avis: ReactNode;
}) {
const [actif, setActif] = useState<'desc' | 'avis'>('desc');
return (
<div className="mt-8">
<div className="flex gap-2 border-b">
<button onClick={() => setActif('desc')}>Description</button>
<button onClick={() => setActif('avis')}>Avis</button>
</div>
<div className="pt-4">
{actif === 'desc' ? description : avis}
</div>
</div>
);
}
// src/app/produits/[id]/page.tsx
import { Onglets } from '@/components/Onglets';
import { ListeAvis } from '@/components/ListeAvis'; // Server Component async
<Onglets
description={<p className="text-gray-700">{produit.description}</p>}
avis={<ListeAvis produitId={produit.id} />}
/>
سحري: Onglets client ويدير حالة الـ tabs، لكن ListeAvis يبقى Server Component يستطيع await db.avis.findMany(...). كلا المحتويين يُعرَضان جانب الخادم في HTML، ثم العميل يكتفي بعرض أحدهما حسب الحالة المحلية. لا JS إضافي للبيانات، فقط للتبديل.
الخطوة 5 — شبكة قرار لكل مكوّن
| السؤال | جواب إيجابي ← | جواب سلبي ← |
|---|---|---|
يستخدم useState، useReducer، useEffect أو hook stateful؟ |
Client إلزامي | تابع |
له معالج حدث DOM (onClick، onChange، onSubmit)؟ |
Client إلزامي | تابع |
يستخدم API متصفّح (localStorage، window، navigator)؟ |
Client إلزامي | تابع |
يستهلك context React عبر useContext؟ |
Client إلزامي | تابع |
| يجب الوصول إلى قاعدة، قراءة ملف، أو متغيّر بيئة سرّي؟ | Server إلزامي | تابع |
يستورد مكتبة Node.js (fs، crypto أصلي)؟ |
Server إلزامي | تابع |
| لا شيء مما سبق ينطبق | Server (افتراضي) | — |
القاعدة الذهبية: ابدأ دائماً بالخادم، انتقل إلى client فقط إن فرضت الشبكة. وحين تنتقل، اعزل أعمق ما يمكن في الشجرة — ليس layout كاملاً فقط لزر قائمة.
الخطوة 6 — التحقق من حجم الحزمة
pnpm build
Next.js يعرض جدولاً لكل route مع عمودين: Size (JS الخاص بهذه route) وFirst Load JS (المجموع المُحمَّل أول وصول). لصفحتنا مع client islands جيدة الوضع، يجب رؤية Size حول 1-3 kB. إن رأيت 50 kB أو أكثر، فاستيراد ثقيل تسلّل جانب العميل — نمطياً مكتبة رسوم، محرّر Markdown، أو date-picker.
للحفر، ثبّت @next/bundle-analyzer. يولّد treemap تفاعلي يُظهر بالضبط أي module يزن كم.
أخطاء شائعة
| الخطأ | السبب | الحل |
|---|---|---|
| «You’re importing a component that needs useState» | مكوّن client مستورَد من Server بلا عزل | أضف "use client" أعلى المكوّن، أو اعزله في ملف مخصّص |
| «Functions cannot be passed directly to Client Components» | محاولة تمرير دالة كـ prop من خادم إلى client | عرّف الدالة جانب العميل، أو اوسمها "use server" لجعلها Server Action |
| «Hydration failed because the initial UI does not match» | عرض خادم وعميل يتباعدان (date، random، locale) | احسب هذه القيم client فقط عبر useEffect أو ثبّتها جانب الخادم |
| مكوّن client لا يستلم props جديدة بعد revalidation | key React سيئ أو cache متصفّح عدواني | أضف key={produit.id} على المكوّن client |
| حزمة JS ضخمة رغم قلّة مكوّنات client | استيراد غير مباشر لمكتبة كبيرة (lodash، moment، ICU) | Tree-shake (lodash-es/debounce)، استبدل moment بـ date-fns أو dayjs |