EduFlow

الوحدة 3 · المكوّنات

الـ Composition بدل الإعدادات

20 دقراءة 4 داختر بحكمة

الدرس في لمحة

تعرّف على انفجار الـ props واستبدله بمكوّنات مركّبة (compound components) تتشارك الحالة عبر الـ context، مع معرفة متى يبقى المكوّن الصغير المُعَدّ بالـ props هو الخيار الأفضل.

  1. 1الإعدادات مقابل الـ composition
  2. 2مشاركة الحالة بين القطع
  3. 3للـ composition تكاليفها أيضًا
  4. 4الانتقال بعيدًا عن مكوّن مُعَدّ بالإعدادات
  5. 5متى تكون الإعدادات هي الصواب

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

كان للإصدار الأول من ShipmentCard في Northwind ستة props. وبعد عام صار له ثلاثة وعشرون: title، subtitle، status، statusTone، showMenu، menuItems، footerText، footerAction، footerActionVariant، hideBorder، compact، imageUrl، imagePosition، وهكذا. كل بطاقة مختلفة قليلًا لدى أحد الفرق صارت prop إضافيًا. وتحوّل كود المكوّن إلى أدغال من الشروط، وصارت صفحة التوثيق تسرد props لا يستطيع أحد أن يحفظها. هذا هو انفجار الـ props (prop explosion)، وكل نظام تصميم يقابله.

الإعدادات مقابل الـ composition

المكوّن المُعَدّ بالإعدادات (configured) يستقبل البيانات ويقرّر التخطيط بنفسه. أما المكوّن المركّب (composed) فيقدّم قطعًا، والمستخدم يرتّبها.

على اليسار: صندوق ShipmentCard واحد تغذّيه قائمة طويلة من الـ props. على اليمين: صندوق Card يحتوي قطع Header وBody وFooter يرتّبها المستخدم. الإعدادات title statusTone menuItems footerText hideBorder +18 أخرى ShipmentCard الـ Composition Card Card.Header Card.Body Card.Footer
مكوّن واحد بـ props كثيرة، مقابل قطع صغيرة يرتّبها المستخدم

هذه بطاقة الشحنة نفسها، مركّبة:

RouteCard.tsx
<Card>
  <Card.Header>
    <Card.Title>Shipment NW-4471</Card.Title>
    <Badge tone="info">In transit</Badge>
    <ShipmentMenu shipmentId="NW-4471" />
  </Card.Header>
  <Card.Body>Rotterdam to Hamburg · 2 pallets</Card.Body>
  <Card.Footer>
    <Button size="sm">Track</Button>
    <Button size="sm" variant="primary">Assign driver</Button>
  </Card.Footer>
</Card>

لم تحتج البطاقة قط إلى prop باسم footerSecondaryAction، لأن Card.Footer هو slot يقبل أي إجراءات. والقائمة مكوّن خاص بالفريق، موضوع حيث تسمح الترويسة. ومع ذلك ما زال النظام يملك المسافات والحدود والطباعة والسلوك المتجاوب لكل قطعة. تحصل الفرق على مرونة في ما يوضع وأين، ويحتفظ النظام بالسيطرة على شكل كل قطعة.

مشاركة الحالة بين القطع

القطع الثابتة مثل Card لا تحتاج إلا إلى التخطيط. أما التفاعلية مثل Tabs فتحتاج إلى حالة مشتركة: التبويب المحدّد، وأيّ لوحة تُعرض. والطريقة المتينة لمشاركتها هي الـ context:

Tabs.tsx
import { createContext, use, useId, useState } from 'react';

const TabsContext = createContext<{
  selected: string;
  select: (value: string) => void;
  baseId: string;
} | null>(null);

function useTabs() {
  const ctx = use(TabsContext);
  if (!ctx) throw new Error('Tabs.* must be used inside <Tabs>');
  return ctx;
}

export function Tabs({ defaultValue, children }: { defaultValue: string; children: React.ReactNode }) {
  const [selected, select] = useState(defaultValue);
  const baseId = useId();
  return <TabsContext value={{ selected, select, baseId }}>{children}</TabsContext>;
}

Tabs.Trigger = function Trigger({ value, children }: { value: string; children: React.ReactNode }) {
  const { selected, select, baseId } = useTabs();
  return (
    <button
      type="button"
      role="tab"
      id={`${baseId}-tab-${value}`}
      aria-selected={selected === value}
      aria-controls={`${baseId}-panel-${value}`}
      onClick={() => select(value)}
    >
      {children}
    </button>
  );
};
// Tabs.List (role="tablist") and Tabs.Panel (role="tabpanel") follow the same idea.

يقرأ الـ trigger الحالة المشتركة أينما كان في الشجرة: داخل tooltip، أو div للتخطيط، أو مكوّن كتبه الفريق. في React 19 تعرض الـ context مباشرة كـ provider ‏(<TabsContext value={…}>) وتقرؤه بـ use. أدوار ARIA مأخوذة من نمط التبويبات في WAI-ARIA، وستتعرّف عليه جيدًا في الدرس التالي؛ أما التعامل مع لوحة المفاتيح فمحذوف هنا لإبقاء المثال قصيرًا.

للـ composition تكاليفها أيضًا

الـ composition ليست مجانية. يكتب المستخدمون markup أكثر، ويمكنهم ترتيب القطع بطرق لم تقصدها، مثل تذييلين أو عنوان داخل المتن. تتعامل Northwind مع هذا بثلاث طرق. كل قطعة مقيّدة: Card.Footer يرتّب الإجراءات، ولا شيء آخر يبدو صحيحًا فيه. والترتيبات الشائعة تُطلَق كوصفات موثّقة تنسخها الفرق. وبعض التركيبات الشائعة جدًا تصبح مغلّفات صغيرة مُعدّة بالـ props ومبنية من القطع المركّبة، مثل ShipmentSummaryCard بأربعة props، فتبقى حالة الـ 80% قصيرة، وتبقى حالة الـ 20% ممكنة.

الانتقال بعيدًا عن مكوّن مُعَدّ بالإعدادات

نادرًا ما تحظى ببداية جديدة. كان ShipmentCard مستخدمًا في 60 شاشة حين أعدنا تصميمه. لم نحذفه؛ بل أعدنا بناءه فوق القطع الجديدة، فصارت الـ props الثلاثة والعشرون تعرض Card.Header وCard.Body وCard.Footer داخليًا. استمرّت كل شاشة موجودة في العمل وحصلت على الأنماط الجديدة مجانًا. ثم علّمنا ShipmentCard كـ deprecated، ووثّقنا المقابل المركّب لكل تركيبة props شائعة، ونقلنا الشاشات فريقًا بعد فريق. بناء الـ API القديم من القطع الجديدة هو ما جعل الانتقال مملًّا، وهذا أعلى مديح يمكن أن يناله انتقال.

متى تكون الإعدادات هي الصواب

ليس كل مكوّن يجب أن يكون مركّبًا. الـ Badge الخاص بالحالة له tone وتسمية؛ والـ Avatar له صورة واسم وحجم. تنويعاتهما قليلة ومغلقة، فتصفهما الـ props وصفًا كاملًا، ولن تضيف الـ composition إلا تعقيدًا شكليًا.

قاعدة عملية من مراجعات الـ API في Northwind: حين تبدأ الـ props بوصف التخطيط (imagePosition، footerAlign) أو بنية المحتوى (menuItems، footerText)، فالمكوّن يريد slots أو قطعًا. وحين تصف الـ props المظهر أو الحالة (tone، size، disabled)، فالإعدادات لا بأس بها.

في التمرين تحكم على اقتراح API لـ Dialog بهذه القواعد. وبعدها تتأكّد أن كل قطعة من هذه القطع تعمل مع لوحة المفاتيح وقارئ الشاشة (screen reader)، افتراضيًا.

أهم النقاط

  • حين تضيف كل حالة استخدام جديدة prop جديدًا، فالمكوّن يُعَدّ من الخارج وسيظلّ يكبر؛ أما الـ composition (التركيب) فتسمح للمستخدمين بترتيب القطع بأنفسهم.
  • المكوّنات المركّبة مثل Card.Header وTabs.Trigger تتشارك الحالة عبر الـ context، لا عبر فحص الأبناء (children).
  • يظلّ النظام مالكًا لشكل كل قطعة وسلوكها، فتضيف الـ composition مرونة دون التخلّي عن الاتّساق.
  • المكوّنات الصغيرة المغلقة مثل Badge أو Avatar لا بأس أن تُعَدّ بالـ props؛ والجأ إلى الـ composition حين تبدأ الـ props بوصف التخطيط أو المحتوى.

قراءات إضافية

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

طلب أحد الفرق prop باسم footerSecondaryActionIcon على ShipmentCard. علامَ يدلّ هذا الطلب على الأرجح؟

السؤال 2 من 3

كيف يجب أن يعرف Tabs.Trigger أيّ تبويب هو المحدّد؟

السؤال 3 من 3

أيّ مكوّن هو الأنسب ليبقى مكوّنًا بسيطًا يُعَدّ بالـ props؟

تدريب

اختر بحكمة

اختر الـ API الخاص بالـ Dialog

تحتاج Northwind إلى Dialog. الاستخدامات المعروفة حتى الآن: تأكيد حذف (عنوان وجملة وزرّان)، ونموذج إسناد سائق فيه قائمة قابلة للبحث، ومعاينة مسار فيها خريطة ولوحة جانبية، ونموذج اشتراك في النشرة البريدية للتسويق. وصلت أربعة اقتراحات للـ API. اختر الاقتراح الذي توافق عليه.

TSX
// A
<Dialog title="Delete route?" body="…" confirmLabel="Delete" confirmVariant="danger"
        cancelLabel="Cancel" showSidePanel={false} onConfirm={…} />

// B
<Dialog open={open} onOpenChange={setOpen}>{anything}</Dialog>

// C
<Dialog open={open} onOpenChange={setOpen}>
  <Dialog.Header><Dialog.Title>Delete route?</Dialog.Title></Dialog.Header>
  <Dialog.Body>…</Dialog.Body>
  <Dialog.Footer>
    <Button onClick={close}>Cancel</Button>
    <Button variant="danger" onClick={remove}>Delete</Button>
  </Dialog.Footer>
</Dialog>

// D
// No shared Dialog: each team builds its own on top of the native <dialog> element.
اختر الـ API الخاص بالـ Dialog