Design patterns
4 minutes readFrontend design patterns: compound components with Context (building a Table)
The pattern behind reusable Table/Select/Tabs components — a family of pieces sharing one Context instead of a tangle of prop drilling.

Most of the pain in component API design comes from one thing: a component that has to support ten different cases ends up with either ten optional props, ten wrapper components, or both. Design patterns exist for exactly this problem. The most useful one for shared UI — a Table, a Select, a set of Tabs — is the compound component pattern, combined with Context to share state between the pieces without threading props through every layer by hand.
The prop-drilling version
A simple reusable Table takes a columns config and a renderCell, and every alignment or width decision has to be passed down through props at every layer — the header needs to know a column is right-aligned, every cell in every row needs to know too, so one alignment prop for one column ends up being passed n+1 times.
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 that same object instead of receiving it through a chain of props.
"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 columns are written once, and the header and each row’s cells read them from 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 whole point of the pattern
Alignment and width are defined exactly once in the columns array, and the header and every cell read precisely that — there is no way for a cell’s alignment to drift away from its header, because both read one object from Context rather than two separate prop chains that happen to agree today. And useTableContext() throws a clear error the moment a Table.Cell is rendered outside <Table>, instead of silently failing with undefined.
A few other patterns worth knowing
- Provider pattern — the same Context + custom hook with an invariant seen above, generalized: whenever several components need to share state without lifting it to a common ancestor.
- Controlled vs uncontrolled — accept an optional
value+onChange, and fall back to internal state when they aren’t given, so the same component works both ways. - Headless / hook-based components — separate the logic (a hook like
useDisclosureoruseCombobox) completely from the markup, so the consumer owns the DOM and only wires up the returned props. - Slot pattern (like Radix’s
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 and the right usage easy.