Component API Design Principles
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.
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.
- 1Every prop is a maintenance promise — add props only when composition cannot solve the problem
- 2Prefer composition (children, slots, compound components) over configuration (boolean and enum props)
- 3If a prop requires documentation to use correctly, the API is wrong
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.
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;
}
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'
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 do | What 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.
| Concern | Chakra UI Approach | Radix + Tailwind Approach |
|---|---|---|
| Variants | colorScheme + variant props (many combos) | className with CVA or Tailwind variants |
| Polymorphism | as prop with TypeScript generics | asChild with Slot component |
| Icons | leftIcon + rightIcon props | Children composition (icon + text) |
| Loading | isLoading + loadingText props | Separate Spinner child + disabled |
| Customization | Style 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.
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.