PatternsDesign Systems
Component API Design
Design intuitive, flexible component APIs that developers love
Component API Design
Good component APIs are intuitive, flexible, and hard to misuse. They make the simple easy and the complex possible.
Principles of Good Component APIs
1. Make Common Cases Easy
// ✅ GOOD: Simple by default
<Button>Click me</Button>
// ❌ BAD: Too verbose for simple case
<Button
variant="primary"
size="medium"
type="button"
disabled={false}
loading={false}
>
Click me
</Button>2. Make Complex Cases Possible
// ✅ GOOD: Composable for complexity
<Button>
<ButtonIcon><PlusIcon /></ButtonIcon>
<ButtonText>Add Item</ButtonText>
<ButtonBadge>5</ButtonBadge>
</Button>
// ❌ BAD: Props explosion
<Button
icon={<PlusIcon />}
text="Add Item"
badge={5}
iconPosition="left"
badgePosition="right"
showIcon
showBadge
/>3. Pit of Success
Design APIs so the correct usage is obvious and incorrect usage is difficult.
// ✅ GOOD: TypeScript prevents misuse
<Select
value={selected}
onChange={(value) => setSelected(value)}
options={[
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2' },
]}
/>
// ❌ BAD: Runtime errors likely
<Select
value="wrong-value" // Not in options
onChange="not a function" // Wrong type
/>Composition Patterns
Compound Components
// ✅ GOOD: Flexible composition
<Card>
<CardHeader>
<CardTitle>Product</CardTitle>
<CardDescription>Details</CardDescription>
</CardHeader>
<CardContent>
<ProductDetails />
</CardContent>
<CardFooter>
<Button>Buy Now</Button>
</CardFooter>
</Card>
// Implementation
interface CardProps {
children: React.ReactNode;
className?: string;
}
export function Card({ children, className }: CardProps) {
return (
<div className={`card ${className || ''}`}>
{children}
</div>
);
}
Card.Header = CardHeader;
Card.Title = CardTitle;
Card.Description = CardDescription;
Card.Content = CardContent;
Card.Footer = CardFooter;Render Props
// Flexible rendering
<DataTable
data={products}
renderRow={(product) => (
<ProductRow
key={product.id}
product={product}
onEdit={() => edit(product)}
/>
)}
renderEmpty={() => <EmptyState />}
renderLoading={() => <Skeleton />}
/>Slots Pattern
interface AlertProps {
title: string;
description: string;
icon?: React.ReactNode;
actions?: React.ReactNode;
variant?: 'info' | 'success' | 'warning' | 'error';
}
export function Alert({ title, description, icon, actions, variant }: AlertProps) {
return (
<div className={`alert alert-${variant}`}>
{icon && <div className="alert-icon">{icon}</div>}
<div className="alert-content">
<div className="alert-title">{title}</div>
<div className="alert-description">{description}</div>
</div>
{actions && <div className="alert-actions">{actions}</div>}
</div>
);
}
// Usage
<Alert
variant="success"
title="Success!"
description="Your changes have been saved"
icon={<CheckIcon />}
actions={
<>
<Button variant="ghost">Undo</Button>
<Button>Continue</Button>
</>
}
/>Prop Naming Conventions
Boolean Props
// ✅ GOOD: Descriptive, positive names
<Button disabled />
<Button loading />
<Button fullWidth />
// ❌ BAD: Ambiguous, negative names
<Button notEnabled />
<Button isNotLoading={false} />
<Button compact={false} />Event Handlers
// ✅ GOOD: onX pattern
<Button onClick={handleClick} />
<Input onChange={handleChange} onBlur={handleBlur} />
<Select onSelect={handleSelect} onClose={handleClose} />
// ❌ BAD: Inconsistent naming
<Button clicked={handleClick} />
<Input changed={handleChange} afterBlur={handleBlur} />
<Select whenSelected={handleSelect} onCloseCallback={handleClose} />Sizes
// ✅ GOOD: T-shirt sizes
<Button size="sm" />
<Button size="md" /> // default
<Button size="lg" />
<Button size="xl" />
// ❌ BAD: Numbers or unclear names
<Button size={2} />
<Button size="middle" />
<Button size="huge" />Variants
// ✅ GOOD: Clear, semantic names
<Button variant="primary" />
<Button variant="secondary" />
<Button variant="ghost" />
<Button variant="destructive" />
// ❌ BAD: Unclear names
<Button variant="type1" />
<Button variant="hollow" />
<Button variant="red" />Sensible Defaults
// Button with smart defaults
export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
type?: 'button' | 'submit' | 'reset';
children: React.ReactNode;
}
export function Button({
variant = 'primary', // Default to primary
size = 'md', // Default to medium
type = 'button', // Prevent form submission by default
disabled = false,
loading = false,
children,
}: ButtonProps) {
return (
<button
type={type}
disabled={disabled || loading}
className={`btn btn-${variant} btn-${size}`}
>
{loading ? <Spinner /> : children}
</button>
);
}
// Usage - minimal props needed
<Button>Submit</Button>TypeScript Best Practices
Discriminated Unions
// ✅ GOOD: Type-safe variants
type ButtonProps =
| { variant: 'primary'; onClick: () => void }
| { variant: 'link'; href: string }
| { variant: 'submit'; form: string };
// TypeScript enforces correct props
<Button variant="primary" onClick={fn} /> // ✓
<Button variant="link" href="/home" /> // ✓
<Button variant="primary" href="/home" /> // ✗ ErrorGeneric Components
// Type-safe select
interface SelectProps<T> {
value: T;
onChange: (value: T) => void;
options: Array<{
value: T;
label: string;
}>;
}
export function Select<T>({ value, onChange, options }: SelectProps<T>) {
// Implementation
}
// Usage - fully typed
const [selected, setSelected] = useState<number>(1);
<Select
value={selected}
onChange={setSelected} // (value: number) => void
options={[
{ value: 1, label: 'One' },
{ value: 2, label: 'Two' },
]}
/>Prop Inheritance
// Extend native element props
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
loading?: boolean;
}
// Gets all native button props + custom props
<Button
variant="primary"
onClick={handleClick}
disabled
aria-label="Submit form"
data-testid="submit-btn"
/>Controlled vs Uncontrolled
Controlled Component
// Parent controls the state
function ControlledInput() {
const [value, setValue] = useState('');
return (
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}Uncontrolled Component
// Component manages its own state
function UncontrolledInput() {
return <Input defaultValue="" />;
}Flexible Pattern (Support Both)
function Input({
value: controlledValue,
defaultValue = '',
onChange,
...props
}: InputProps) {
const [internalValue, setInternalValue] = useState(defaultValue);
// Controlled if value prop provided
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!isControlled) {
setInternalValue(e.target.value);
}
onChange?.(e);
};
return <input value={value} onChange={handleChange} {...props} />;
}
// Works both ways
<Input value={controlled} onChange={setControlled} />
<Input defaultValue="initial" />Forwarding Refs
// Allow parent access to DOM element
export const Input = React.forwardRef<
HTMLInputElement,
InputProps
>(function Input({ className, ...props }, ref) {
return (
<input
ref={ref}
className={`input ${className || ''}`}
{...props}
/>
);
});
// Usage
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<Input ref={inputRef} />
<Button onClick={() => inputRef.current?.focus()}>
Focus Input
</Button>
</>
);
}Polymorphic Components
// Component can render as different 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 TextProps {
color?: string;
}
export function Text<C extends React.ElementType = 'span'>({
as,
color,
children,
...props
}: PolymorphicComponentProp<C, TextProps>) {
const Component = as || 'span';
return (
<Component style={{ color }} {...props}>
{children}
</Component>
);
}
// Usage - renders as different elements
<Text>Default span</Text>
<Text as="p">Paragraph</Text>
<Text as="h1">Heading</Text>
<Text as="a" href="/home">Link</Text>Documentation
JSDoc Comments
/**
* Primary button component for user actions.
*
* @example
* ```tsx
* <Button variant="primary" onClick={handleClick}>
* Submit
* </Button>
* ```
*/
export function Button({
/**
* The visual style of the button
* @default 'primary'
*/
variant = 'primary',
/**
* Size of the button
* @default 'md'
*/
size = 'md',
/**
* Whether the button is disabled
* @default false
*/
disabled = false,
children,
}: ButtonProps) {
// Implementation
}Best Practices
- Consistent Naming: Use same patterns across all components
- Type Safety: Leverage TypeScript fully
- Composition: Prefer composition over configuration
- Sensible Defaults: Make simple cases require minimal props
- Flexibility: Support advanced use cases
- Documentation: Clear examples and prop descriptions
- Accessibility: Built-in ARIA attributes
- Ref Forwarding: Allow parent DOM access
- Controlled/Uncontrolled: Support both patterns
- Native Props: Extend native element props
Common Pitfalls
❌ Props explosion: 50+ props per component
✅ Composition and defaults
❌ Inconsistent naming: Different conventions
✅ Establish component API guidelines
❌ No TypeScript: Runtime errors
✅ Full TypeScript support
❌ Inflexible: Can't customize when needed
✅ Escape hatches for advanced users
❌ Poor defaults: Require many props
✅ Sensible defaults for common cases
Great component APIs feel natural to use—invest time in getting them right!