Back to blog

Frontend Design Patterns: Compound Components with Context (Building a Table)

July 24, 20264 min read
Frontend Design Patterns: Compound Components with Context (Building a Table)

Most component API pain comes from one thing: a component that needs to support ten variations ends up with ten optional props, or ten wrapper components, or both. Design patterns exist to give that problem a shape. The most useful one for building shared UI — a Table, a Select, a Tabs component — is the compound component pattern, paired with Context to share state between the pieces without passing props down manually at every level.

The prop-drilling version

A naive reusable Table takes a columns config and a renderCell function, and every alignment/width decision has to be threaded through props at every level — the header needs to know a column is right-aligned, and so does every single cell in every row, and now that's an alignment prop passed n+1 times for one column.

Compound components + Context

Instead, expose a family of components — Table, Table.Head, Table.Row, Table.Cell — that all read from one Context provided once at the top. The column definitions (key, header text, alignment, width) live in exactly one place, and both the header and every cell in every row read the same object instead of receiving it as a prop chain.

"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>
  );
};

Using it — the column config is written once, and both the header and every row's cells read it back through 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>
  );
}

Why this is the point of the pattern

Alignment and width are defined exactly once, in the columns array, and consumed identically by the header and by every cell — there's no way for a cell's alignment to drift out of sync with its column header, because they both read the same object out of Context instead of two separate prop chains that happen to agree today. The useTableContext() helper also throws a clear error the moment a Table.Cell is rendered outside a <Table>, instead of failing silently with undefined.

A few other patterns worth knowing

  • Provider pattern — the same Context + custom-hook-with-invariant trick above, generalized: any time several components need to share state without lifting it all the way to a common ancestor's props.

  • Controlled vs. uncontrolled — accept an optional value + onChange, and fall back to internal state when they're not provided, so the same component works both ways.

  • Headless components / hooks — separate the logic (a useDisclosure or useCombobox-style hook) from the markup entirely, so the consumer owns the DOM and just wires up the returned props.

  • Slot pattern (Radix-style asChild) — let the consumer swap the rendered element (a button that should render as a Link) without an extra wrapper element.

None of these are about being clever — they're about making the wrong usage hard to write and the right usage the path of least resistance.