Front-end Engineering Lab
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" />        // ✗ Error

Generic 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

  1. Consistent Naming: Use same patterns across all components
  2. Type Safety: Leverage TypeScript fully
  3. Composition: Prefer composition over configuration
  4. Sensible Defaults: Make simple cases require minimal props
  5. Flexibility: Support advanced use cases
  6. Documentation: Clear examples and prop descriptions
  7. Accessibility: Built-in ARIA attributes
  8. Ref Forwarding: Allow parent DOM access
  9. Controlled/Uncontrolled: Support both patterns
  10. 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!

On this page