Skip to content

Component API Design Principles

advanced15 min read

The Pit of Success

Here is the thing most developers get backwards: they design a component, then try to prevent misuse with documentation. The best component APIs flip this. They make wrong usage structurally impossible and correct usage the path of least resistance.

This concept — the "pit of success" — comes from Rico Mariani at Microsoft. Instead of climbing toward correct usage, you fall into it.

// BAD: easy to misuse
<Button color="blue" size="big" outline rounded disabled loading>
  Submit
</Button>

// GOOD: pit of success — variant constrains valid combinations
<Button variant="primary" size="lg" pending>
  Submit
</Button>

The first API lets you combine outline, color, and rounded in ways that produce visual garbage. The second uses a variant that maps to a tested, designed combination. You literally cannot create a broken visual state.

Mental Model

Think of component APIs like a vending machine. A bad API gives you a keyboard to type anything — including nonsense. A good API gives you buttons for valid choices. You can only press buttons that dispense real products.

Minimal Surface Area

Every prop you add is a maintenance promise. It is a new axis of variation you must test, document, and never break. The best components do a lot with very few props.

// TOO MANY PROPS — configuration monster
interface CardProps {
  title: string;
  subtitle?: string;
  image?: string;
  imagePosition?: "top" | "left" | "right";
  footer?: ReactNode;
  headerAction?: ReactNode;
  bordered?: boolean;
  shadow?: "sm" | "md" | "lg";
  padding?: "none" | "sm" | "md" | "lg";
  onClick?: () => void;
  href?: string;
  target?: string;
}

This Card component has 12 props. That is 12 axes of variation. Some combinations conflict (onClick + href), some produce ugly results (imagePosition="left" with no image). The testing matrix is enormous.

// COMPOSITION OVER CONFIGURATION
interface CardProps {
  children: ReactNode;
  asChild?: boolean;
}

// Usage — flexible, zero invalid states
<Card>
  <CardImage src="/hero.jpg" />
  <CardContent>
    <CardTitle>Launch Day</CardTitle>
    <CardDescription>We shipped it.</CardDescription>
  </CardContent>
  <CardFooter>
    <Button variant="primary">Read More</Button>
  </CardFooter>
</Card>

The compound approach has fewer props per component, and the structure enforces valid layouts. You cannot put an image in the footer by accident.

Key Rules
  1. 1Every prop is a maintenance promise — add props only when composition cannot solve the problem
  2. 2Prefer composition (children, slots, compound components) over configuration (boolean and enum props)
  3. 3If a prop requires documentation to use correctly, the API is wrong
Quiz
A component has 8 boolean props that can be combined freely. How many visual states must you test?

Sensible Defaults

A component should work correctly with zero configuration. The most common use case should require the fewest props.

// BAD: requires boilerplate for the common case
<Dialog
  open={isOpen}
  onClose={() => setIsOpen(false)}
  closeOnOverlayClick={true}
  closeOnEsc={true}
  trapFocus={true}
  restoreFocus={true}
  role="dialog"
  aria-modal={true}
>
  <DialogContent>...</DialogContent>
</Dialog>

// GOOD: sensible defaults, override only when needed
<Dialog open={isOpen} onOpenChange={setIsOpen}>
  <DialogContent>...</DialogContent>
</Dialog>

The second version defaults to everything a dialog should do: trap focus, close on Escape, close on overlay click, restore focus on close. You only pass props when you need to deviate from the standard.

Quiz
Which default behavior should a Modal component NOT have enabled by default?

Controlled vs Uncontrolled

This distinction is one of the most important API decisions you will make. A controlled component receives its state as props. An uncontrolled component manages its own state internally.

// UNCONTROLLED — component owns its state
<Accordion defaultOpenItems={["section-1"]}>
  <AccordionItem value="section-1">...</AccordionItem>
  <AccordionItem value="section-2">...</AccordionItem>
</Accordion>

// CONTROLLED — parent owns the state
const [openItems, setOpenItems] = useState(["section-1"]);

<Accordion openItems={openItems} onOpenItemsChange={setOpenItems}>
  <AccordionItem value="section-1">...</AccordionItem>
  <AccordionItem value="section-2">...</AccordionItem>
</Accordion>

The best component libraries support both. The pattern: if the controlled prop is provided, use it. Otherwise, fall back to internal state.

function useControllableState<T>(
  controlledValue: T | undefined,
  defaultValue: T,
  onChange?: (value: T) => void,
) {
  const [internalValue, setInternalValue] = useState(defaultValue);
  const isControlled = controlledValue !== undefined;
  const value = isControlled ? controlledValue : internalValue;

  const setValue = useCallback(
    (next: T | ((prev: T) => T)) => {
      const nextValue =
        typeof next === "function" ? (next as (prev: T) => T)(value) : next;
      if (!isControlled) setInternalValue(nextValue);
      onChange?.(nextValue);
    },
    [isControlled, value, onChange],
  );

  return [value, setValue] as const;
}
Common Trap

Never switch a component between controlled and uncontrolled during its lifetime. React cannot track the transition correctly. If value is undefined on mount, the component is uncontrolled — passing value later causes bugs. Use separate value and defaultValue props to make the intent explicit.

Discriminated Unions for Mutually Exclusive Props

This is where TypeScript transforms component API design. When props are mutually exclusive, do not use optional booleans. Use discriminated unions.

// BAD: nothing prevents passing both href and onClick
interface ButtonProps {
  href?: string;
  onClick?: () => void;
  target?: string;
}

// GOOD: TypeScript enforces mutual exclusivity
type ButtonProps =
  | {
      as: "button";
      onClick: () => void;
      href?: never;
      target?: never;
    }
  | {
      as: "a";
      href: string;
      target?: string;
      onClick?: never;
    };

With the discriminated union, TypeScript will show a red squiggly if you pass href to a button or onClick to an anchor. The compiler enforces your design constraints.

// TypeScript ERROR — exactly what we want
<Button as="button" href="/about" />
//                   ~~~~
// Type 'string' is not assignable to type 'undefined'
Quiz
What TypeScript technique prevents passing both onClick and href to a Button component?

Inversion of Control

The open/closed principle says components should be open for extension but closed for modification. Inversion of control is how you achieve this: give consumers power over behavior without them needing to fork your code.

// CLOSED — component controls everything
<Select
  options={options}
  filterFn={(option, query) => option.label.includes(query)}
  renderOption={(option) => <span>{option.label}</span>}
/>

// OPEN — consumer controls rendering and behavior
<Select value={selected} onValueChange={setSelected}>
  <SelectTrigger>
    <SelectValue placeholder="Choose a framework" />
  </SelectTrigger>
  <SelectContent>
    {frameworks.map((fw) => (
      <SelectItem key={fw.value} value={fw.value}>
        <FrameworkIcon name={fw.icon} />
        {fw.label}
      </SelectItem>
    ))}
  </SelectContent>
</Select>

The second API inverts control. The consumer decides how to render each item, what the trigger looks like, and how the content is structured. The component provides behavior (keyboard navigation, focus management, ARIA) without dictating appearance.

What developers doWhat they should do
Adding renderItem, renderHeader, renderFooter props for every customization point
Render props proliferate quickly and create a tangled prop-drilling API. Composition lets consumers use standard JSX patterns they already know.
Use compound components or slots so consumers compose their own structure
Using boolean props for mutually exclusive states like isLoading and isError and isSuccess
Three booleans allow 8 combinations, 7 of which are invalid. A union type makes impossible states unrepresentable.
Use a single status prop with a union type: status: 'idle' | 'loading' | 'error' | 'success'
Accepting className and style and color and bg and border as separate props
Style-related props create a parallel styling system that conflicts with whatever CSS methodology the project uses.
Accept className (or asChild for full control) and let Tailwind or CSS handle the rest

Real-World API Design Comparison

Let us look at how mature libraries approach button API design.

ConcernChakra UI ApproachRadix + Tailwind Approach
VariantscolorScheme + variant props (many combos)className with CVA or Tailwind variants
Polymorphismas prop with TypeScript genericsasChild with Slot component
IconsleftIcon + rightIcon propsChildren composition (icon + text)
LoadingisLoading + loadingText propsSeparate Spinner child + disabled
CustomizationStyle props (px, py, bg, etc.)Full CSS control via className

The trend in modern React (2025-2026) is clear: less configuration, more composition. Radix, Ark UI, Base UI, and React Aria all favor compound components and composition patterns (asChild, render props) over prop-heavy APIs.

Quiz
Why do modern component libraries like Radix prefer the asChild pattern over the as prop?
Interview Question

Design a component API for a notification/toast system. What props would you expose? How would you handle different toast types (success, error, warning, info)? How would you handle stacking, positioning, and auto-dismiss? Walk through your reasoning for each API decision.

1/8