Advanced Component Patterns: A Reference
Advanced Component Patterns: A Reference#
Four patterns I keep reaching for. Less narrative than my usual posts -- this is meant as a reference you can come back to. Code-heavy by design.
TL;DR
- Compound components: shared implicit state via context, consumer controls composition
- Headless hooks: logic separated from UI entirely -- the pattern behind every good UI library
- Render props (evolved): still useful when the consumer needs to control rendering based on derived state
- Polymorphic components: change the underlying HTML element without losing type safety
Compound Components#
The idea: multiple components share state through context, and the consumer controls how they're composed.
import React, { createContext, useContext, useState, useId } from "react";
interface TabsContextValue {
activeTab: string;
setActiveTab: (id: string) => void;
baseId: string;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Tabs compound components must be used inside <Tabs>");
return ctx;
}
interface TabsProps {
defaultTab: string;
children: React.ReactNode;
}
export function Tabs({ defaultTab, children }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab);
const baseId = useId();
return (
<TabsContext.Provider value={{ activeTab, setActiveTab, baseId }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
export function TabList({ children }: { children: React.ReactNode }) {
return (
<div role="tablist" className="tab-list">
{children}
</div>
);
}
export function Tab({ id, children }: { id: string; children: React.ReactNode }) {
const { activeTab, setActiveTab, baseId } = useTabs();
const isActive = activeTab === id;
return (
<button
role="tab"
id={`${baseId}-tab-${id}`}
aria-controls={`${baseId}-panel-${id}`}
aria-selected={isActive}
onClick={() => setActiveTab(id)}
className={isActive ? "tab tab--active" : "tab"}
>
{children}
</button>
);
}
export function TabPanel({ id, children }: { id: string; children: React.ReactNode }) {
const { activeTab, baseId } = useTabs();
if (activeTab !== id) return null;
return (
<div
role="tabpanel"
id={`${baseId}-panel-${id}`}
aria-labelledby={`${baseId}-tab-${id}`}
>
{children}
</div>
);
}
Usage:
<Tabs defaultTab="overview">
<TabList>
<Tab id="overview">Overview</Tab>
<Tab id="activity">Activity</Tab>
<Tab id="settings">Settings</Tab>
</TabList>
<TabPanel id="overview"><ProjectOverview project={project} /></TabPanel>
<TabPanel id="activity"><ActivityFeed events={events} /></TabPanel>
<TabPanel id="settings"><ProjectSettings project={project} /></TabPanel>
</Tabs>
Consumer controls the markup. Reorder tabs, add icons, wrap panels in grid layouts -- none of it requires changes to Tabs internals. That's the payoff.
Render Props (The Hooks Version)#
Hooks eliminated most render prop use cases. But render props still make sense when the consumer controls what's rendered based on derived state from the component.
A DataTable that exposes sort/filter/pagination but lets you decide the layout:
interface DataTableRenderProps<T> {
rows: T[];
sortColumn: keyof T | null;
sortDirection: "asc" | "desc";
onSort: (column: keyof T) => void;
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}
interface DataTableProps<T> {
data: T[];
pageSize?: number;
children: (props: DataTableRenderProps<T>) => React.ReactNode;
}
export function DataTable<T extends Record<string, unknown>>({
data,
pageSize = 20,
children,
}: DataTableProps<T>) {
const [sortColumn, setSortColumn] = useState<keyof T | null>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(1);
const sorted = useMemo(() => {
if (!sortColumn) return data;
return [...data].sort((a, b) => {
const av = a[sortColumn];
const bv = b[sortColumn];
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
return sortDirection === "asc" ? cmp : -cmp;
});
}, [data, sortColumn, sortDirection]);
const totalPages = Math.ceil(sorted.length / pageSize);
const rows = sorted.slice((page - 1) * pageSize, page * pageSize);
function onSort(column: keyof T) {
if (sortColumn === column) {
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortColumn(column);
setSortDirection("asc");
}
setPage(1); // reset to first page on sort change
}
return (
<>{children({ rows, sortColumn, sortDirection, onSort, page, totalPages, onPageChange: setPage })}</>
);
}
The caller owns the entire table layout. The component owns only the logic.
Headless Hooks#
The most underused pattern. A headless hook encapsulates all stateful logic for a UI concept and returns props/handlers -- without rendering anything. This is how Downshift works. This is how Radix UI, Headless UI, and React Aria work.
interface UseDisclosureOptions {
defaultOpen?: boolean;
onOpen?: () => void;
onClose?: () => void;
}
interface UseDisclosureReturn {
isOpen: boolean;
open: () => void;
close: () => void;
toggle: () => void;
getToggleProps: (overrides?: React.HTMLAttributes<HTMLElement>) => {
onClick: () => void;
"aria-expanded": boolean;
"aria-controls"?: string;
};
getContentProps: (id: string) => {
id: string;
hidden: boolean;
};
}
export function useDisclosure({
defaultOpen = false,
onOpen,
onClose,
}: UseDisclosureOptions = {}): UseDisclosureReturn {
const [isOpen, setIsOpen] = useState(defaultOpen);
const open = useCallback(() => {
setIsOpen(true);
onOpen?.();
}, [onOpen]);
const close = useCallback(() => {
setIsOpen(false);
onClose?.();
}, [onClose]);
const toggle = useCallback(() => {
setIsOpen((prev) => {
const next = !prev;
if (next) onOpen?.();
else onClose?.();
return next;
});
}, [onOpen, onClose]);
const getToggleProps = useCallback(
(overrides: React.HTMLAttributes<HTMLElement> = {}) => ({
...overrides,
onClick: toggle,
"aria-expanded": isOpen,
}),
[toggle, isOpen]
);
const getContentProps = useCallback(
(id: string) => ({
id,
hidden: !isOpen,
}),
[isOpen]
);
return { isOpen, open, close, toggle, getToggleProps, getContentProps };
}
Same hook, completely different UI:
// Accordion
function AccordionItem({ title, children }: { title: string; children: React.ReactNode }) {
const { getToggleProps, getContentProps } = useDisclosure();
return (
<div>
<button {...getToggleProps()}>{title}</button>
<div {...getContentProps("accordion-content")}>{children}</div>
</div>
);
}
// Command palette trigger
function CommandPaletteTrigger() {
const { open, isOpen } = useDisclosure({
onOpen: () => trackEvent("command_palette_opened"),
});
return <button onClick={open} disabled={isOpen}>Open Command Palette</button>;
}
Polymorphic Components#
A Text that can be h1 through h6, p, or span -- with correct HTML attribute types for each:
type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>["ref"];
type AsProp<C extends React.ElementType> = {
as?: C;
};
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
type PolymorphicComponentProp<
C extends React.ElementType,
Props = Record<string, never>
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
type PolymorphicComponentPropWithRef<
C extends React.ElementType,
Props = Record<string, never>
> = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> };
type TextOwnProps = {
color?: "primary" | "muted" | "danger";
size?: "sm" | "md" | "lg" | "xl";
};
type TextProps<C extends React.ElementType> = PolymorphicComponentPropWithRef<C, TextOwnProps>;
export const Text = React.forwardRef(function Text<C extends React.ElementType = "span">(
{ as, color = "primary", size = "md", children, ...rest }: TextProps<C>,
ref: PolymorphicRef<C>
) {
const Component = as ?? "span";
return (
<Component
ref={ref}
className={`text text--${color} text--${size}`}
{...rest}
>
{children}
</Component>
);
}) as <C extends React.ElementType = "span">(props: TextProps<C>) => React.ReactElement | null;
<Text as="h1" id="page-title"> gets h1 HTML attributes. <Text as="a" href="/about"> gets anchor props including href. TypeScript catches you passing href to a button or type="submit" to an a tag.
The type gymnastics look verbose. They are. But they catch real bugs.
Quick note on when to reach for each pattern:
- Headless hook first -- gives you something testable immediately
- Compound components when multiple components need to share that state
- Render props when the consumer needs to control rendering based on derived state
- Polymorphic only when your design system genuinely needs it (the TypeScript cost is real)
Don't over-engineer compound components for things with one clear rendering shape. And always memoize context values -- if the provider creates a new object on every render, every consumer re-renders:
// Bad: new object every render
<TabsContext.Provider value={{ activeTab, setActiveTab, baseId }}>
// Good: stable reference
const value = useMemo(
() => ({ activeTab, setActiveTab, baseId }),
[activeTab, baseId]
);
<TabsContext.Provider value={value}>