Type-safe Component Patterns in React
4 min read
TypeScriptReactComponents
Building reusable components with TypeScript requires careful consideration of APIs, flexibility, and developer experience. Here are patterns I've refined while building design systems used by 6+ teams.
Polymorphic Components
Create components that can render as different HTML elements:
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 = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
interface ButtonProps {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
}
const Button = <C extends React.ElementType = 'button'>({
as,
variant = 'primary',
size = 'md',
children,
...props
}: PolymorphicComponentProp<C, ButtonProps>) => {
const Component = as || 'button';
return (
<Component
className={`btn btn-${variant} btn-${size}`}
{...props}
>
{children}
</Component>
);
};
// Usage
<Button>Default button</Button>
<Button as="a" href="/login">Link button</Button>
<Button as={Link} to="/dashboard">Router link</Button>
Compound Components
Build flexible APIs with compound patterns:
interface SelectContextValue {
value: string;
onChange: (value: string) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
const SelectContext = React.createContext<SelectContextValue | null>(null);
const useSelectContext = () => {
const context = React.useContext(SelectContext);
if (!context) {
throw new Error("Select components must be used within Select");
}
return context;
};
const Select = ({
value,
onValueChange,
children,
}: {
value: string;
onValueChange: (value: string) => void;
children: React.ReactNode;
}) => {
const [isOpen, setIsOpen] = React.useState(false);
return (
<SelectContext.Provider
value={{
value,
onChange: onValueChange,
isOpen,
setIsOpen,
}}
>
<div className="select">{children}</div>
</SelectContext.Provider>
);
};
const SelectTrigger = ({ children }: { children: React.ReactNode }) => {
const { isOpen, setIsOpen } = useSelectContext();
return (
<button onClick={() => setIsOpen(!isOpen)} aria-expanded={isOpen}>
{children}
</button>
);
};
const SelectContent = ({ children }: { children: React.ReactNode }) => {
const { isOpen } = useSelectContext();
if (!isOpen) return null;
return <div className="select-content">{children}</div>;
};
const SelectItem = ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => {
const { onChange, setIsOpen } = useSelectContext();
return (
<button
onClick={() => {
onChange(value);
setIsOpen(false);
}}
>
{children}
</button>
);
};
// Usage
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger>Choose option</SelectTrigger>
<SelectContent>
<SelectItem value="react">React</SelectItem>
<SelectItem value="vue">Vue</SelectItem>
<SelectItem value="svelte">Svelte</SelectItem>
</SelectContent>
</Select>;
Discriminated Unions for Variants
Type-safe variant props:
type ButtonVariant =
| { variant: 'primary'; destructive?: never }
| { variant: 'destructive'; destructive: true }
| { variant: 'secondary'; destructive?: never };
interface BaseButtonProps {
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
children: React.ReactNode;
}
type ButtonProps = BaseButtonProps & ButtonVariant;
const Button = ({ variant, destructive, size = 'md', ...props }: ButtonProps) => {
const className = `btn btn-${variant} btn-${size}`;
return <button className={className} {...props} />;
};
// Type-safe usage
<Button variant="primary" /> // ✅
<Button variant="destructive" destructive /> // ✅
<Button variant="primary" destructive /> // ❌ Type error
Generic Components
Build flexible data components:
interface DataTableProps<T> {
data: T[];
columns: Array<{
key: keyof T;
header: string;
render?: (value: T[keyof T], item: T) => React.ReactNode;
}>;
onRowClick?: (item: T) => void;
}
const DataTable = <T extends Record<string, any>>({
data,
columns,
onRowClick,
}: DataTableProps<T>) => {
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, index) => (
<tr key={index} onClick={() => onRowClick?.(item)}>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render
? col.render(item[col.key], item)
: String(item[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
};
// Usage with full type safety
interface User {
id: number;
name: string;
email: string;
role: "admin" | "user";
}
<DataTable<User>
data={users}
columns={[
{ key: "name", header: "Name" },
{ key: "email", header: "Email" },
{
key: "role",
header: "Role",
render: (role) => (
<Badge variant={role === "admin" ? "primary" : "secondary"}>
{role}
</Badge>
),
},
]}
onRowClick={(user) => navigate(`/users/${user.id}`)}
/>;
Key Takeaways
- Polymorphic components provide flexibility without sacrificing type safety
- Compound patterns create intuitive APIs for complex components
- Discriminated unions prevent invalid prop combinations
- Generic components enable reusability across data types
- Context + hooks encapsulate complex state logic
These patterns enable building component libraries that are both powerful and pleasant to use, with TypeScript providing guardrails for correct usage.