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.
More posts

Authentication with better-auth in Next.js: A First-Hand Setup
The exact setup behind this site's own admin login: server config, the Next.js catch-all route handler, the client, and the two-layer middleware + layout protection that actually secures /admin.

Recharts in Next.js + Tailwind: Theming and Custom Styling
Making Recharts feel native to a Tailwind design system: correct Client Component boundaries, wiring colors to CSS variables for automatic dark mode, custom tooltips/legends, and handling RTL locales.