أنماط التصميم
قراءة في 4 دقائقأنماط تصميم الواجهات: مكوّنات Compound مع Context (بناء جدول Table)
النمط الذي يقف خلف مكوّنات Table وSelect وTabs القابلة لإعادة الاستخدام — عائلة من القطع تتشارك Context واحدًا بدل فوضى تمرير الخصائص عبر المستويات.

معظم متاعب تصميم واجهة برمجية لمكوّن ما تأتي من أمر واحد: مكوّن مطلوب منه دعم عشر حالات مختلفة ينتهي بعشر خصائص اختيارية، أو بعشرة مكوّنات غلاف (wrappers)، أو بالاثنين معًا. وأنماط التصميم موجودة لحل هذه المشكلة بالذات. وأنفعها لبناء واجهات مشتركة — جدول Table، أو قائمة Select، أو تبويبات Tabs — هو نمط المكوّنات المركّبة (compound components)، مقرونًا بـ Context لمشاركة الحالة بين القطع دون تمرير الخصائص يدويًا عبر كل طبقة.
نسخة تمرير الخصائص عبر المستويات
يأخذ جدول بسيط قابل لإعادة الاستخدام إعداد columns ودالة renderCell، ويجب تمرير كل قرار يخص المحاذاة أو العرض عبر الخصائص في كل طبقة — فالرأس يجب أن يعرف أن عمودًا ما محاذى لليمين، وكل خلية في كل صف يجب أن تعرف ذلك أيضًا، فتُمرَّر خاصية محاذاة واحدة لعمود واحد n+1 مرة.
المكوّنات المركّبة مع Context
بدل ذلك، اعرض عائلة من المكوّنات — Table وTable.Head وTable.Row وTable.Cell — تقرأ جميعها من Context واحد يُوفَّر مرة واحدة في الأعلى. وتعيش تعريفات الأعمدة (المفتاح، نص الرأس، المحاذاة، العرض) في مكان واحد فقط، ويقرأ الرأس وكل خلية في كل صف الكائن نفسه بدل أن يتلقوه عبر سلسلة من الخصائص.
"use client";
import { createContext, useContext, type ReactNode } from "react";
type Align = "start" | "center" | "end";
type Column = { key: string; header: string; align?: Align; width?: string };
type TableContextValue = { columns: Column[]; striped?: boolean };
const TableContext = createContext<TableContextValue | null>(null);
function useTableContext() {
const ctx = useContext(TableContext);
if (!ctx) throw new Error("Table.* components must be rendered inside <Table>");
return ctx;
}
const alignClass: Record<Align, string> = {
start: "text-start",
center: "text-center",
end: "text-end",
};
export function Table({
columns,
striped,
children,
}: {
columns: Column[];
striped?: boolean;
children: ReactNode;
}) {
return (
<TableContext.Provider value={{ columns, striped }}>
<table className="w-full border-collapse text-sm">{children}</table>
</TableContext.Provider>
);
}
Table.Head = function TableHead() {
const { columns } = useTableContext();
return (
<thead>
<tr className="border-b border-border">
{columns.map((col) => (
<th
key={col.key}
style={{ width: col.width }}
className={"py-2 px-3 font-medium " + alignClass[col.align ?? "start"]}
>
{col.header}
</th>
))}
</tr>
</thead>
);
};
Table.Row = function TableRow({
index,
children,
}: {
index: number;
children: ReactNode;
}) {
const { striped } = useTableContext();
return (
<tr className={striped && index % 2 === 1 ? "bg-muted/40" : undefined}>
{children}
</tr>
);
};
Table.Cell = function TableCell({
columnKey,
children,
}: {
columnKey: string;
children: ReactNode;
}) {
const { columns } = useTableContext();
const column = columns.find((c) => c.key === columnKey);
return (
<td className={"py-2 px-3 " + alignClass[column?.align ?? "start"]}>
{children}
</td>
);
};الاستخدام — تُكتب الأعمدة مرة واحدة، ويقرؤها الرأس وخلايا كل صف من Context:
const columns = [
{ key: "name", header: "Name" },
{ key: "role", header: "Role" },
{ key: "salary", header: "Salary", align: "end" as const, width: "120px" },
];
function TeamTable({ people }: { people: Person[] }) {
return (
<Table columns={columns} striped>
<Table.Head />
<tbody>
{people.map((person, i) => (
<Table.Row key={person.id} index={i}>
<Table.Cell columnKey="name">{person.name}</Table.Cell>
<Table.Cell columnKey="role">{person.role}</Table.Cell>
<Table.Cell columnKey="salary">{person.salary}</Table.Cell>
</Table.Row>
))}
</tbody>
</Table>
);
}لماذا هذا هو جوهر النمط
حُددت المحاذاة والعرض مرة واحدة فقط في المصفوفة columns، ويقرأ الرأس وكل خلية القيمة نفسها بالضبط — فلا سبيل إلى أن تنفصل محاذاة خلية عن رأسها، لأن كليهما يقرأ كائنًا واحدًا من Context، لا سلسلتي خصائص منفصلتين تتطابقان اليوم صدفةً. كما أن useTableContext() يرمي خطأً واضحًا في اللحظة التي يُصيَّر فيها Table.Cell خارج <Table>، بدل أن يفشل بصمت مع undefined.
أنماط أخرى تستحق المعرفة
- نمط Provider — الحيلة نفسها: Context مع hook مخصص يتحقق من شرط، معمَّمةً: كلما احتاجت عدة مكوّنات إلى مشاركة حالة دون رفعها إلى سلف مشترك.
- المضبوط مقابل غير المضبوط (controlled / uncontrolled) — اقبل
valueوonChangeاختياريين، وارجع إلى حالة داخلية إن لم يُعطَيا، ليعمل المكوّن نفسه بالطريقتين. - المكوّنات بلا واجهة (headless) المعتمدة على hooks — افصل المنطق (hook مثل
useDisclosureأوuseCombobox) تمامًا عن الـ markup، ليملك المستهلك الـ DOM ويكتفي بربط الخصائص المُعادة. - نمط Slot (مثل
asChildفي Radix) — دع المستهلك يبدّل العنصر المصيَّر (زر يجب أن يُصيَّر كرابط Link) دون عنصر غلاف إضافي.
لا يتعلق شيء من هذا بالتذاكي — بل بجعل الاستخدام الخاطئ صعبًا والاستخدام الصحيح سهلًا.