Skip to main content

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
  • asChild pattern 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:

import React, { createContext, useContext, useState } from "react";

const TabsContext = createContext<{
active: string;
setActive: (v: string) => void;
} | null>(null);

function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Must be inside <Tabs>");
return ctx;
}

function Tabs({ defaultValue, children }: {
defaultValue: string;
children: React.ReactNode;
}) {
const [active, setActive] = useState(defaultValue);
return (
  <TabsContext.Provider value={{ active, setActive }}>
    <div style={{ border: "1px solid #e5e7eb", borderRadius: 8, overflow: "hidden" }}>
      {children}
    </div>
  </TabsContext.Provider>
);
}

function TabList({ children }: { children: React.ReactNode }) {
return (
  <div style={{ display: "flex", borderBottom: "1px solid #e5e7eb", background: "#f9fafb" }}>
    {children}
  </div>
);
}

function Tab({ value, children }: { value: string; children: React.ReactNode }) {
const { active, setActive } = useTabs();
return (
  <button onClick={() => setActive(value)} style={{
    padding: "8px 16px", border: "none", cursor: "pointer",
    background: active === value ? "#fff" : "transparent",
    fontWeight: active === value ? 600 : 400,
    borderBottom: active === value ? "2px solid #3b82f6" : "2px solid transparent",
  }}>{children}</button>
);
}

function TabPanel({ value, children }: { value: string; children: React.ReactNode }) {
const { active } = useTabs();
if (active !== value) return null;
return <div style={{ padding: 16 }}>{children}</div>;
}

export default function App() {
return (
  <div style={{ fontFamily: "sans-serif", padding: "1.5rem" }}>
    <h2>Compound Components Demo</h2>
    <Tabs defaultValue="overview">
      <TabList>
        <Tab value="overview">Overview</Tab>
        <Tab value="tasks">Tasks</Tab>
        <Tab value="settings">Settings</Tab>
      </TabList>
      <TabPanel value="overview">
        <h3>Project Overview</h3>
        <p>This is the overview panel. Try clicking other tabs!</p>
      </TabPanel>
      <TabPanel value="tasks">
        <h3>Tasks</h3>
        <p>Here are your tasks. Try adding a 4th tab!</p>
      </TabPanel>
      <TabPanel value="settings">
        <h3>Settings</h3>
        <p>Configure your project here.</p>
      </TabPanel>
    </Tabs>
  </div>
);
}