Chapter 13: Component Patterns
Well-designed components are the building blocks of a maintainable UI. This chapter covers the patterns that make components reusable, type-safe, and composable.
Compound Components
Compound components share implicit state through React Context, letting the parent manage logic while children control rendering:
// shared/components/ui/tabs.tsx
import { createContext, useContext, useState } from "react";
interface TabsContextValue {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs() {
const context = useContext(TabsContext);
if (!context) throw new Error("Tab components must be used within <Tabs>");
return context;
}
// Parent — manages state
interface TabsProps {
defaultValue: string;
value?: string;
onValueChange?: (value: string) => void;
children: React.ReactNode;
}
export function Tabs({ defaultValue, value, onValueChange, children }: TabsProps) {
const [internalValue, setInternalValue] = useState(defaultValue);
const activeTab = value ?? internalValue;
const setActiveTab = (tab: string) => {
setInternalValue(tab);
onValueChange?.(tab);
};
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div>{children}</div>
</TabsContext.Provider>
);
}
// Children — consume state
export function TabsList({ children }: { children: React.ReactNode }) {
return (
<div className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1" role="tablist">
{children}
</div>
);
}
export function TabsTrigger({ value, children }: { value: string; children: React.ReactNode }) {
const { activeTab, setActiveTab } = useTabs();
return (
<button
role="tab"
aria-selected={activeTab === value}
className={cn(
"inline-flex items-center justify-center rounded-sm px-3 py-1.5 text-sm font-medium transition-all",
activeTab === value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab(value)}
>
{children}
</button>
);
}
export function TabsContent({ value, children }: { value: string; children: React.ReactNode }) {
const { activeTab } = useTabs();
if (activeTab !== value) return null;
return <div role="tabpanel">{children}</div>;
}
Usage:
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="tasks">Tasks</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="overview"><ProjectOverview /></TabsContent>
<TabsContent value="tasks"><TaskList /></TabsContent>
<TabsContent value="settings"><ProjectSettings /></TabsContent>
</Tabs>
Polymorphic Components
Components that render as different HTML elements based on a prop:
// shared/components/ui/text.tsx
import { type ElementType, type ComponentPropsWithoutRef } from "react";
type TextProps<T extends ElementType = "p"> = {
as?: T;
variant?: "body" | "caption" | "label" | "overline";
} & ComponentPropsWithoutRef<T>;
const variantStyles = {
body: "text-base text-foreground",
caption: "text-sm text-muted-foreground",
label: "text-sm font-medium leading-none",
overline: "text-xs font-medium uppercase tracking-wider text-muted-foreground",
};
export function Text<T extends ElementType = "p">({
as,
variant = "body",
className,
...props
}: TextProps<T>) {
const Component = as || "p";
return <Component className={cn(variantStyles[variant], className)} {...props} />;
}
Usage:
<Text>Default paragraph</Text>
<Text as="span" variant="caption">Small caption</Text>
<Text as="h2" variant="label">Form Label</Text>
<Text as="div" variant="overline">Section Header</Text>
TypeScript ensures that props match the underlying element — as="a" allows href, as="button" allows onClick, etc.
Discriminated Union Props
Use TypeScript discriminated unions when a component has mutually exclusive prop combinations:
// A button that is either a link or a button, never both
type ButtonLinkProps =
| ({
as: "link";
href: string;
target?: string;
} & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">)
| ({
as?: "button";
onClick?: () => void;
type?: "button" | "submit" | "reset";
} & React.ButtonHTMLAttributes<HTMLButtonElement>);
export function ActionButton(props: ButtonLinkProps) {
if (props.as === "link") {
const { as, ...rest } = props;
return <a className={cn(buttonStyles)} {...rest} />;
}
const { as, ...rest } = props;
return <button className={cn(buttonStyles)} {...rest} />;
}
// TypeScript enforces correctness:
<ActionButton as="link" href="/projects" /> // ✅
<ActionButton as="link" onClick={() => {}} /> // ❌ links don't have onClick
<ActionButton onClick={() => {}} /> // ✅
<ActionButton href="/projects" /> // ❌ buttons don't have href
Custom Hooks for Logic Extraction
Separate logic from presentation by extracting stateful behavior into custom hooks:
// features/projects/hooks/use-project-filters.ts
import { useCallback, useMemo } from "react";
import { useSearch, useNavigate } from "@tanstack/react-router";
export function useProjectFilters() {
const search = useSearch({ from: "/_authenticated/projects/" });
const navigate = useNavigate();
const setFilter = useCallback(
(key: string, value: string | undefined) => {
navigate({
search: (prev) => ({
...prev,
[key]: value,
page: 1, // Reset page when filters change
}),
});
},
[navigate]
);
const clearFilters = useCallback(() => {
navigate({ search: { page: 1 } });
}, [navigate]);
const hasActiveFilters = useMemo(
() => Boolean(search.status || search.search || search.priority),
[search]
);
return {
filters: search,
setFilter,
clearFilters,
hasActiveFilters,
};
}
The component becomes purely presentational:
function ProjectFilters() {
const { filters, setFilter, clearFilters, hasActiveFilters } = useProjectFilters();
return (
<div className="flex gap-3">
<Select
value={filters.status}
onValueChange={(v) => setFilter("status", v)}
>
{/* options */}
</Select>
<Input
value={filters.search ?? ""}
onChange={(e) => setFilter("search", e.target.value || undefined)}
placeholder="Search projects..."
/>
{hasActiveFilters && (
<Button variant="ghost" onClick={clearFilters}>
Clear filters
</Button>
)}
</div>
);
}
Controlled vs. Uncontrolled Components
Support both patterns for flexibility:
interface ComboboxProps<T> {
options: T[];
getLabel: (option: T) => string;
getValue: (option: T) => string;
// Controlled mode
value?: string;
onValueChange?: (value: string) => void;
// Uncontrolled mode
defaultValue?: string;
placeholder?: string;
}
export function Combobox<T>({
options,
getLabel,
getValue,
value: controlledValue,
onValueChange,
defaultValue = "",
placeholder,
}: ComboboxProps<T>) {
const [internalValue, setInternalValue] = useState(defaultValue);
// Use controlled value if provided, otherwise use internal state
const isControlled = controlledValue !== undefined;
const currentValue = isControlled ? controlledValue : internalValue;
const handleChange = (newValue: string) => {
if (!isControlled) {
setInternalValue(newValue);
}
onValueChange?.(newValue);
};
// ... render logic
}
Composition over Inheritance
React components compose through children and render props, not inheritance:
// ✅ Composition: DataCard wraps Card with specific content structure
function DataCard({
title,
value,
trend,
icon,
}: {
title: string;
value: string | number;
trend?: { value: number; direction: "up" | "down" };
icon?: React.ReactNode;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{trend && (
<p className={cn(
"text-xs",
trend.direction === "up" ? "text-green-600" : "text-red-600"
)}>
{trend.direction === "up" ? "↑" : "↓"} {Math.abs(trend.value)}%
</p>
)}
</CardContent>
</Card>
);
}
The asChild Pattern (Radix Slot)
Allow a component's behavior to be applied to any child element:
import { Slot } from "@radix-ui/react-slot";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
variant?: "default" | "ghost";
}
export function Button({ asChild, variant = "default", className, ...props }: ButtonProps) {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant }), className)} {...props} />;
}
// Usage: Button styles applied to a Link
<Button asChild variant="ghost">
<Link to="/projects">View Projects</Link>
</Button>
Summary
- ✅ Compound components share state implicitly through Context
- ✅ Polymorphic components render as different elements with type-safe props
- ✅ Discriminated unions enforce mutually exclusive prop combinations
- ✅ Custom hooks extract logic, keeping components presentational
- ✅ Controlled/uncontrolled support gives consumers flexibility
- ✅ Composition over inheritance — wrap and combine, do not extend
- ✅
asChildpattern applies component behavior to any child element
🎮 Try It: Compound Component Pattern
Edit the tabs below — try adding a new tab or changing the default value: