خشبة منهج React — المسرح الحي · ١٣ إقليم · بلا مهلة الستار مرفوع
الإقليم ٠٨١٣ إقليماً

الإقليم ٠٨ — خريطة المسرح

كل الأقاليم السابقة كانت عن مكوّنٍ واحد أو خطافٍ واحد. هذا الإقليم يكبّر الصورة: عشرات المكوّنات والخطافات، وشخصان أو أكثر يعملون عليها في وقتٍ واحد. لا مفهوم React جديد هنا — فقط سؤال: كيف تُرتّب ما تعلّمتَه بحيث لا ينهار تحت وزنه؟

يبني فوق: المكوّن كوحدة شكل (١)، الخطاف المخصّص كوحدة منطق (٦). النطاق: أين تعيش الملفات على القرص، ولماذا هذا قرارٌ هندسيٌّ لا ذوقٌ شخصي. لا مفهوم React جديد هنا — تطبيقٌ لكل ما سبق على مقياس مشروعٍ كامل. يفتح: ورشة الأزياء (٩)، خارطة الجولة (١٠).

اللغز المستفزّ

تبني تطبيقاً فيه ميزتان منفصلتان تماماً: المخزون (Inventory) والفوترة (Billing). كل ميزة تحتاج صفحات، مكوّنات عرض، خطافات حالة، ونداءات API. الطريقة "البديهية" الأولى: نظّم حسب النوع:

src/ ├── pages/ │ ├── InventoryListPage.tsx │ ├── InventoryItemPage.tsx │ ├── BillingListPage.tsx │ └── BillingInvoicePage.tsx ├── components/ │ ├── InventoryTable.tsx │ ├── InventoryFilterBar.tsx │ ├── BillingSummaryCard.tsx │ └── InvoiceRow.tsx ├── hooks/ │ ├── useInventoryItems.ts │ ├── useInventoryFilters.ts │ ├── useBillingInvoices.ts │ └── useInvoiceTotals.ts └── api/ ├── inventoryApi.ts └── billingApi.ts

أنت تعمل على المخزون، زميلك على الفوترة. كلاكما يحتاج إضافة ملفٍّ جديد إلى components/ في نفس اليوم. كلاكما يفتح نفس المجلّد. لو كان فيه ملفّ فهرسة (components/index.ts يُصدّر الكل)، كلاكما يُعدّله — تعارض دمجٍ (merge conflict) شبه مضمون، رغم أن عملكما لا يتقاطع منطقياً بأي شكل. وحتى بلا تعارضٍ حرفي: افتح hooks/ بعد شهر — عشرون ملفاً، أسماؤها فقط تُخبرك أي ميزةٍ تخصّ. لا حدود، لا ملكية واضحة.

السؤال المستفزّ: كل ملفٍّ هنا "يخصّ" ميزةً واحدة بوضوح (مخزون أو فوترة) — فلماذا نظّمناها حسب نوع الملف (صفحة/مكوّن/خطاف) بدل الميزة التي تخصّها؟

ليش: نظّم حسب "من يملك هذا"، لا "ما شكله"

اسأل سؤالاً مختلفاً: لو أردتُ حذف ميزة الفوترة بالكامل غداً، كم ملفاً علي لمسه، وفي كم مجلّد مختلف؟ في التنظيم أعلاه: أربعة مجلّدات، تبحث يدوياً عن كل ملفٍّ اسمه يبدأ بـBilling/Invoice. في تنظيمٍ حسب الميزة أولاً: مجلّدٌ واحد، تحذفه، انتهى.

src/features/ ├── inventory/ │ ├── pages/ │ ├── components/ │ ├── hooks/ │ └── api.ts └── billing/ ├── pages/ ├── components/ ├── hooks/ └── api.ts

الآن أنت وزميلك تعملان في مجلّدين مختلفين تماماً. لا ملفّ مشتركٌ بينكما إلا ما تتّفقان عليه صراحةً (القسم القادم). هذا يحلّ مشكلة التصادم بنيوياً، لا بالتواصل والتنسيق اليدوي المستمر.

ليش وكيف: حدٌّ ثانٍ — الشكل عن المنطق، داخل كل ميزة

افتح ملفّاً واحداً بلا أي فصل:

function TaskList() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");

  useEffect(() => {
    fetchTasks().then((data) => { setTasks(data); setStatus("ready"); })
      .catch(() => setStatus("error"));
  }, []);

  if (status === "loading") return <Spinner />;
  if (status === "error") return <p>خطأ في التحميل</p>;

  return (
    <ul className="divide-y">
      {tasks.map((t) => (
        <li key={t.id} className="flex items-center justify-between p-4">
          <span className={t.done ? "line-through opacity-50" : ""}>{t.text}</span>
          <Checkbox checked={t.done} />
        </li>
      ))}
    </ul>
  );
}

الآن تحتاج في مكانٍ آخر من التطبيق (لوحة تحكّمٍ مصغّرة) نفس البيانات بالضبط، لكن معروضة كملخّصٍ صغير: "٣ مهامٍ متبقّية" فقط، بلا قائمة. بما أن جلب البيانات محبوسٌ داخل TaskList نفسها، لا طريقة لإعادة استخدامه إلا نسخ منطق useEffect/fetch بالكامل مجدداً. هذا نفس درس الإقليم ٦ بالضبط — لكن السبب هذه المرّة ليس "نموذجان متشابهان"، بل "شكلان مختلفان لنفس البيانات":

// المنطق — لا يعرف شيئاً عن الشكل النهائي
function useTasks() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");

  useEffect(() => {
    fetchTasks().then((data) => { setTasks(data); setStatus("ready"); })
      .catch(() => setStatus("error"));
  }, []);

  return { tasks, status };
}

// شكلٌ أول — لا يعرف شيئاً عن fetch أو useEffect
function TaskListFull() {
  const { tasks, status } = useTasks();
  if (status === "loading") return <Spinner />;
  if (status === "error") return <p>خطأ في التحميل</p>;
  return <ul>{tasks.map((t) => <li key={t.id}>{t.text}</li>)}</ul>;
}

// شكلٌ ثانٍ — نفس المنطق بالضبط، عرضٌ مختلف كلياً
function TaskSummaryWidget() {
  const { tasks, status } = useTasks();
  if (status !== "ready") return null;
  const remaining = tasks.filter((t) => !t.done).length;
  return <span>{remaining} مهامٍ متبقّية</span>;
}

القاعدة المشتقّة: مكوّن "شكل" يستقبل بياناته (عبر props أو خطافٍ مخصّص) ويقرّر فقط كيف تُعرَض — لا من أين أتت ولا متى تُجلَب. خطافٌ مخصّص يحمل كل قرارات "المنطق" — لا يعرف ولا يهتمّ كيف سيُعرَض ما يُرجعه. هذا الفصل يعني: تغييرٌ بصريّ بحت (لون، تخطيط) لا يخاطر بلمس منطق الجلب، وتغييرٌ في منطق الجلب (endpoint جديد، تخزينٌ مؤقّت) لا يجبرك على لمس JSX في كل مكانٍ يُستهلَك فيه.

اللغز/البناء من الصفر — الجزء الأول

اللغز أ. ابنِ تطبيقاً وهمياً بميزتين مستقلّتين (اختر موضوعاً غير الأمثلة أعلاه — مثلاً "ملاحظات" و"تذكيرات"). ابدأ بتنظيمٍ حسب النوع (pages/, components/, hooks/ على مستوى الجذر تخلط الميزتين). ثم أعد هيكلته بالكامل إلى features/notes/ وfeatures/reminders/، كل واحدة بمجلّداتها الفرعية الخاصة. اكتب بجملة واحدة: أي ملفٍّ كان يصعب معرفة "لمن يتبع" قبل إعادة الهيكلة، وكيف صار واضحاً بعدها.

اللغز ب. كرّر تمرين TaskList/useTasks أعلاه على إحدى ميزتيك: ابنِ منطقاً في خطافٍ مخصّص واحد، واستهلكه من شكلين مختلفين حقاً (لا نسخة معدّلة طفيفاً — شكلان بغرضين مختلفين فعلياً: قائمة كاملة، وملخّص). القيد الحاكم: مكوّنا الشكل يُمنَع عليهما استدعاء fetch أو useEffect مباشرة — فقط استهلاك الخطاف.

ليش: مقارنة بالبديل الأكثر صرامة — Feature-Sliced Design

التنظيم أعلاه (حسب الميزة، بحدٍّ بسيط شكل/منطق) يُسمّى غالباً feature-based — مرن، قواعده اتفاقية لا مفروضة. يوجد بديلٌ أكثر صرامة يستحق معرفته: Feature-Sliced Design (FSD)، مواصفةٌ رسمية بطبقاتٍ إجبارية:

Feature-based (ما بنيتَه للتو)FSD الرسمية
التجميعحسب الميزة (notes, reminders)طبقاتٌ إجبارية: app → pages → widgets → features → entities → shared
قواعد الاستيرادمرنة، اتفاقيةحديدية: كل طبقة تستورد فقط ممّا تحتها — لا صعوداً أبداً
الوحدة الأساسيةمجلّد "ميزة" شامل"entity" (كائن نطاق، كـuser/task) منفصلٌ عن "feature" (فعلٌ يعطي قيمة، كـtoggle-task)
منحنى التعلّمبسيطحادّ — طقوسٌ وقواعدُ تسمية صارمة

القاعدة الحديدية في FSD ("الطبقة تستورد فقط ممّا تحتها") تحلّ مشكلةً لم تظهر بعد في مشروعك الصغير لكنها حتمية في مشروعٍ كبير يعيش سنين: الاعتماديات الدائرية والصاعدة. تخيّل ميزة "أ" تستورد شيئاً من ميزة "ب"، وميزة "ب" تستورد شيئاً من "أ" لاحقاً — الآن لا يمكن حذف أو فهم أيٍّ منهما بمعزلٍ عن الأخرى، ومجلَّدا "الميزتين المستقلّتين" أصبحا متشابكين فعلياً. الطبقات الإجبارية تمنع هذا بنيوياً: لا شيء أسفل يمكنه معرفة شيءٍ أعلاه.

متى تستحقّ FSD الثمن؟ فريقٌ كبير، مشروعٌ طويل العمر، عشرات الميزات المتشابكة محتملة. متى هي مبالغة؟ فريقٌ صغير، عدد ميزاتٍ محدود، مشروعٌ في مراحله الأولى — الطقوس والقواعد الصارمة تُبطئك بلا فائدةٍ تقابلها بعد. القرار ليس "الأفضل مطلقاً"، بل مقايضة تتناسب مع حجم المشكلة الفعلي.

اللغز/البناء من الصفر — الجزء الثاني: دقّق قاعدة الاستيراد

اللغز ج. أُعطيتَ رسم اعتمادياتٍ (import graph) لمشروعٍ صغيرٍ مُنظَّمٍ بطبقات FSD مبسّطة: shared → entities → features → pages. الملفات التالية موجودة (بلا محتوىً فعلي — فقط سطور import):

// src/shared/ui/Button.tsx
export function Button() { /* ... */ }

// src/entities/task/model.ts
import { formatDueDate } from "@/features/task-reminder/utils";   // ⚠️
export type Task = { id: string; dueDate: string };

// src/features/task-reminder/utils.ts
export function formatDueDate(iso: string): string { /* ... */ }

// src/pages/TaskBoard.tsx
import { Task } from "@/entities/task/model";
import { Button } from "@/shared/ui/Button";

حدّد بدقّة أي سطر استيراد يخالف قاعدة "الطبقة تستورد فقط ممّا تحتها" (رتّب الطبقات: shared أسفل، ثم entities، ثم features، ثم pages في الأعلى). اكتب أي طبقةٍ تستورد من طبقةٍ أعلى منها خطأً. ثم أصلح الاعتمادية: أعد ترتيب أين يجب أن تعيش formatDueDate فعلياً بحيث يزول الخرق تماماً بلا تغيير أي سلوكٍ برمجي — فقط موقع الملف.

كيف: قاعدة الترقية إلى shared

قاعدةٌ عملية بسيطة تناسب كلا الأسلوبين: ملفٌّ يبدأ داخل ميزته. أول لحظة يحتاجه قسمٌ ثانٍ (لا أول احتمال نظري — أول استخدامٍ فعلي)، يُنقَل إلى shared/ (أو entities/ في FSD لو كان يمثّل كائن نطاق). قبل ذلك، أي ترقيةٍ مبكرة "تحسّباً" مجرّد تخمين — والتخمين الخاطئ يُنتج طبقة shared/ منتفخة بأشياء يستخدمها قسمٌ واحد فعلياً، فتفقد الكلمة معناها.

الخلاصة — أين تتّصل هذه العقدة بالشجرة

الخلاصة
  • ما بنيتَه: تنظيماً حسب الميزة يحلّ تصادم الفرق بنيوياً، وفصلاً بين الشكل (يستهلك) والمنطق (خطافٌ مخصّص، يُنتج) يحلّ إعادة استخدام نفس البيانات بأشكالٍ متعدّدة، ومقارنةً واعية ببديلٍ أكثر صرامة (FSD) وقاعدة استيرادٍ دقّقتها بنفسك.
  • العقدة الجديدة: *تنظيم مشروعٍ كامل حول ملكية الميزة وحدود الاعتمادية* — أول درسٍ في هذا المنهج لا يضيف API جديدة لـReact، بل يرتّب كل ما تعلّمتَه.
  • بابٌ مفتوح: كل مكوّن "شكل" بنيناه حتى الآن يفترض أنه يبني كل شيءٍ من الصفر — حتى الأزرار والحقول. مشروعٌ حقيقي لا يحتمل هذا: كل زرٍّ يجب أن "يتصرّف" صحيحاً (تركيز، تعطيل، حالات لمس) بلا أن يُعاد اختراعه في كل ميزة. → الإقليم ٩.

الوصلة بمشروعك

الوصلة بمشروعك

هيكلة frontend/src عندكم (features/auth, features/curriculum-builder, ...) هي بالضبط الأسلوب الأول (feature-based) الذي بنيتَه في اللغز أ — قرارٌ موثَّقٌ صراحة في مستندات المشروع لصالح البساطة على صرامة FSD، بالضبط للسبب الذي درستَه هنا: فريقٌ صغير، حجمٌ لا يبرّر الطقوس بعد. والحدّ بين pages/+ components/ (شكل) وhooks/+api.ts+context/ (منطق) داخل كل قسمٍ عندهم هو نفس الحدّ الذي بنيتَه في اللغز ب حرفياً.

البذرة التالية: عندنا الآن مكان واضح لكل ملف. لكن "مكوّنات الشكل" التي نبنيها منذ الإقليم ١ كانت دائماً عناصر HTML خام (<button>, <input>) — بلا أي ضمانةٍ أنها تتصرّف صحيحاً حين يضغط عليها مستخدمٌ حقيقي، بلوحة مفاتيح، أو بقارئ شاشة. كيف تبني عنصراً "جاهزاً" مرّةً واحدة، تثق به في كل مكان؟