بازگشت به وبلاگ

دیزاین پترن‌های فرانت‌اند: کامپوننت‌های Compound با Context (ساختن یه Table)

۲ مرداد ۱۴۰۵4 دقیقه مطالعه
دیزاین پترن‌های فرانت‌اند: کامپوننت‌های Compound با Context (ساختن یه Table)

بیشتر دردسر طراحی API یه کامپوننت از یه چیز میاد: کامپوننتی که باید ده تا حالت مختلف رو پشتیبانی کنه، آخرش یا ده تا prop اختیاری داره، یا ده تا کامپوننت wrapper، یا هردو. دیزاین پترن‌ها دقیقاً برای همین مسئله شکل می‌دن. مفیدترینش برای ساختن UI مشترک — یه Table، یه Select، یه Tabs — الگوی compound component‌هاست، همراه با Context برای اشتراک state بین تیکه‌ها بدون اینکه prop رو دستی توی هر لایه پاس بدی.

نسخه‌ی prop-drilling

یه Table ساده‌ی reusable یه columns config و یه renderCell می‌گیره، و هر تصمیم چینش/عرض باید از توی props توی هر لایه رد بشه — هدر باید بدونه یه ستون راست‌چین شده، و هر سلول توی هر ردیف هم باید بدونه، و الان یه prop چینش برای یه ستون، n+1 بار پاس داده می‌شه.

Compound Component + Context

به‌جاش، یه خانواده از کامپوننت‌ها expose کن — Table، Table.Head، Table.Row، Table.Cell — که همه از یه Context که یه‌بار بالا provide شده می‌خونن. تعریف ستون‌ها (کلید، متن هدر، چینش، عرض) دقیقاً یه‌جا زندگی می‌کنه، و هم هدر هم هر سلول توی هر ردیف همون یه object رو می‌خونن، نه اینکه از یه زنجیره‌ی prop بگیرنش.

"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 تعریف شدن، و هم هدر هم هر سلول عیناً همون رو می‌خونن — هیچ راهی نیست که چینش یه سلول از هدرش جدا بیفته، چون هردو یه object رو از Context می‌خونن، نه دو زنجیره‌ی prop جدا که فعلاً باهم یکی‌ان. تابع useTableContext() هم دقیقاً همون لحظه که یه Table.Cell بیرون از <Table> رندر بشه یه خطای واضح می‌ده، نه اینکه بی‌سروصدا با undefined fail بشه.

چندتا پترن دیگه که ارزش دونستن دارن

  • الگوی Provider — همون ترفند Context + هوک سفارشی با invariant که بالا دیدیم، تعمیم‌یافته: هروقت چندتا کامپوننت باید state رو بدون بالا بردنش تا یه ancestor مشترک به اشتراک بذارن.

  • Controlled در برابر uncontrolled — یه value + onChange اختیاری قبول کن، و اگه نداده شدن به state داخلی برگرد، تا همون کامپوننت هر دو مدلی کار کنه.

  • کامپوننت‌های headless / هوک‌محور — منطق (یه هوک شبیه useDisclosure یا useCombobox) رو کاملاً از markup جدا کن، تا مصرف‌کننده مالک DOM باشه و فقط prop‌های برگشتی رو وصل کنه.

  • الگوی Slot (شبیه asChild توی Radix) — بذار مصرف‌کننده المان رندرشده رو عوض کنه (یه دکمه که باید به‌شکل Link رندر بشه) بدون یه المان wrapper اضافه.

هیچ‌کدوم از این‌ها درباره‌ی زرنگ‌بازی نیستن — درباره‌ی سخت‌کردن استفاده‌ی غلط و راحت‌کردن استفاده‌ی درست‌ان.