Topic 22.2
Designing Component APIs
In one line
A component's props are its API: name them like the platform (onChange, disabled), support both controlled (value + onChange) and uncontrolled (defaultValue) use, prefer composition with children over piles of configuration props, express looks as variant/size, and leave escape hatches: className, ...rest and ref.
Think of it like this
A wall socket. Anyone can use it without reading a manual, because it follows a standard shape, it has one obvious job, and it doesn't care whether you plug in a lamp or a laptop. A good component is the same: its props follow the shapes people already know from HTML, it does one job well, and it accepts things it didn't plan for. The API (application programming interface) of a component is simply its props: the names, types and behaviour other developers rely on.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Component API
- The props a component accepts and how it behaves: what other developers program against.
- Controlled component
- A component whose value comes from the
valueprop; the parent owns the state and every change goes throughonChange. - Uncontrolled component
- A component that keeps its own state, starting from
defaultValue; the parent can listen but doesn't own it. - Configuration API
- A component steered by many props (
showX,xAlign,xItems). - Composition API
- A component that accepts children or sub-components that the caller arranges.
- Variant
- One of a fixed set of named looks or behaviours, such as
variant="danger". - Polymorphic component
- A component that can render as a different element or component, usually through an
asprop. - Slot / asChild
- A pattern where a component renders its child instead of its own element and merges its props into it.
- Escape hatch
- A way for callers to go beyond what the component planned:
className,...rest,ref.
Step by step
01Start from how people will call it
Write the call sites first, as if the component already existed. If the call reads well, the API is good. Here a Stepper (a number with − and + buttons) is wanted in two places: a booking form that doesn't care about the number until submit, and a guest picker that must enforce a limit.
// Doesn't care until submit: uncontrolled
<Stepper label="Tickets" defaultValue={1} />
// Must enforce a rule: controlled
<Stepper label="Guests" value={guests} onChange={(n) => n <= 4 && setGuests(n)} />02Support both modes with one hook
The hook keeps internal state for uncontrolled use, ignores it when value is given, and always calls onChange. Every component in the design system (Tabs, Accordion, Dialog's open) reuses it.
export function useControllableState({ value, defaultValue, onChange }) {
const [inner, setInner] = useState(defaultValue);
const isControlled = value !== undefined;
const current = isControlled ? value : inner;
function setValue(next) {
if (!isControlled) setInner(next);
onChange?.(next);
}
return [current, setValue];
}03Configuration versus composition
Before: every new layout need became a prop. After: the caller arranges parts. The composition version has fewer props and handles cases the author never imagined (two buttons on the left, a badge in the title).
// Before: configuration
<Card
title="Invoice #42"
subtitle="Due Friday"
icon={<FileIcon />}
footerButtons={[{ label: "Pay", onClick: pay }, { label: "Later" }]}
footerAlign="right"
showDivider
/>
// After: composition
<Card>
<Card.Header>
<FileIcon /> Invoice #42 <Badge>Overdue</Badge>
</Card.Header>
<Card.Body>Due Friday</Card.Body>
<Card.Footer>
<Button onClick={pay}>Pay</Button>
<Button variant="secondary">Later</Button>
</Card.Footer>
</Card>04Variants, polymorphism and escape hatches together
A design-system Button puts all of it in a few lines: maps for variant and size, an as prop, an asChild slot, className merged last, ...rest spread, and ref passed through. The order of the spread matters: put {...rest} after your defaults so callers can override type, but merge className yourself so callers can't wipe your classes by accident.
export function Button({ variant = "primary", size = "md", as: Tag = "button",
asChild = false, className, ref, ...rest }) {
const Comp = asChild ? Slot : Tag;
const extra = Comp === "button" ? { type: "button" } : {};
return (
<Comp ref={ref}
className={cx("btn", VARIANTS[variant], SIZES[size], className)}
{...extra} {...rest} />
);
}05Decide the defaults on purpose
Defaults are part of the API. A Button defaults to type="button" because the HTML default, submit, silently submits any surrounding form. A Dialog defaults to closing on Escape. A Stepper defaults min to 0. Each default should be the safe, common choice, and each should be overridable.
Try it yourself
- 1
Make Tickets controlled
In the Stepper example, add
const [tickets, setTickets] = useState(1);toAppand change the Tickets stepper tovalue={tickets} onChange={setTickets}. Predict: does the log change? Does the screen behave any differently?Now change it to
onChange={() => {}}. Predict what the + button does, then check. A controlled component with a parent that never updates is frozen, exactly like an<input value="x" />with noonChange. - 2
Add a variant
Add
ghost: "btn-ghost"toVARIANTSand a.btn-ghost { background: transparent; color: #0b7285; }rule, then render<Button variant="ghost">Cancel</Button>. One map entry, no newif. - 3
Break the className merge
In
Button, moveclassNameout of the destructuring so it stays inrest(remove it from the parameter list and fromcx). Predict theBuybutton's class list, then run. The caller's class replaced every design-system class.
Code & diagrams
Tickets is uncontrolled: it counts by itself and only reports. Guests is controlled: the third click asks for 5, the parent refuses, and the screen stays at 4, because the screen always shows the parent's `value`.
Preview
Console
Nothing logged yet.Expected console output
Tickets changed to 2
Tickets changed to 3
Guests asked for 3
Guests asked for 4
Guests asked for 5
Screen shows Tickets = 3 and Guests = 4Read the printed HTML. `as="a"` changed the tag; `asChild` produced **one** `<a>` with the button classes merged into the link's own class; the caller's `wide` was added to, not swapped for, the button classes; `aria-label`, `data-testid` and `disabled` passed through `...rest`; and the ref reached the real `<button>`.
Preview
Console
Nothing logged yet.Expected console output
<button class="btn btn-primary btn-md" type="button">Save</button>
<button class="btn btn-danger btn-sm" type="button" aria-label="Delete draft">Delete</button>
<a class="btn btn-secondary btn-md" href="#docs">Docs</a>
<a href="#help" class="btn btn-secondary btn-md with-icon">Help</a>
<button class="btn btn-primary btn-md wide" type="button" data-testid="buy" disabled="">Buy</button>
Focused: Saveimport type { ComponentProps } from "react";
type Variant = "primary" | "secondary" | "danger";
type Size = "sm" | "md";
// Everything a native <button> accepts (onClick, disabled, aria-*, ref...),
// plus our own props. Omit nothing: that is the escape hatch.
type ButtonProps = ComponentProps<"button"> & {
variant?: Variant;
size?: Size;
asChild?: boolean;
};
// Controlled/uncontrolled as a type: you may pass value OR defaultValue, not both.
type StepperProps = { label: string; min?: number; max?: number } & (
| { value: number; onChange: (next: number) => void; defaultValue?: never }
| { defaultValue?: number; onChange?: (next: number) => void; value?: never }
);
<Stepper label="Guests" value={2} defaultValue={1} onChange={() => {}} />;
// error TS2322: Type '{ label: string; value: number; defaultValue: number; onChange: () => void; }'
// is not assignable to type 'IntrinsicAttributes & StepperProps'.
// Types of property 'defaultValue' are incompatible.
// Type 'number' is not assignable to type 'undefined'.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
A wrapper that switches from uncontrolled to controlled
Write function TextField({ value, onChange }) { return <input value={value} onChange={onChange} />; } and render <TextField value={name} onChange={...} /> where name starts as undefined (for example from useState() with no argument) and is set later.
Break #2
Spreading rest after your own handler
In the Stepper's buttons, write <button onClick={() => setCount(count + 1)} {...rest}> and let callers pass onClick to log analytics.
Myth vs fact
Myth
More props make a component more reusable.
Fact
Every prop is another branch to test and another promise you can't easily remove. Composition (children and sub-components) usually covers more cases with fewer props.
Myth
Controlled components are always better.
Fact
Controlled mode gives the parent power at the cost of more code at every call site. Many callers only need a starting value and the final result, which uncontrolled mode gives for free. Good library components support both.
Myth
as and asChild are the same thing.
Fact
as swaps the element the component renders. asChild renders your element and merges props into it, so it works with any component, including router Links, without the library knowing about them.
Myth
Spreading ...rest is sloppy.
Fact
Spreading the rest onto the root element is what makes a component behave like the native element it wraps (ARIA, data attributes, test ids, native events). The sloppy part is spreading it in the wrong order or onto the wrong element.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The value of
isControlledshould not change during a component's life. Production hooks (Radix'suseControllableState, React Aria'suseControlledState) warn in development when a component switches modes, just like React does for<input>. - ▸
Calling the parent's
onChangeinside asetStateupdater (setInner(prev => { onChange(next); return next; })) is a bug: updaters must be pure and run twice in StrictMode, so the callback fires twice. Call it next to the state update, in the event handler. - ▸
cloneElementis marked as uncommon in React's docs because it makes data flow hard to follow and it can't see through wrapper components.asChildis one of the few well-accepted uses; React's docs suggest render props or context for most other cases. - ▸
For TypeScript,
ComponentProps<"button">(orComponentPropsWithRef) gives every native attribute for free, and a discriminated union withnevermakes "value or defaultValue, not both" a compile error rather than a runtime surprise.
Remember this
- 1
Name props like the platform. Event callbacks start with
on(onChange,onSelect,onOpenChange) and the code that handles them inside starts withhandle. Booleans read as facts:disabled,open,required(match the HTML names when the HTML idea exists, rather than inventingisDisabled). A callback that reports a new value should pass the value, not the DOM event, when there is no single DOM event behind it:onValueChange(3). Consistent names mean people can guess your API. - 2
Controlled and uncontrolled. You met these for inputs in Topics 5.1 and 5.3 and for lifted state in Topic 6.3. A reusable component should support both. Uncontrolled: the caller passes
defaultValue(a starting value) and the component keeps its own state. Controlled: the caller passesvalueandonChange, owns the state, and can refuse or change any update. The usual rule: ifvalueis notundefined, the component is controlled. A small hook, often calleduseControllableState, holds this logic once for every component in your design system. - 3
Composition over configuration. A configuration API answers every new need with another prop:
<Card title subtitle icon footerButtons showDivider headerAlign ... />. Each prop is easy to add and impossible to remove, and the component becomes a maze ofifs. A composition API gives parts that callers arrange:<Card><Card.Header>…</Card.Header><Card.Body>…</Card.Body></Card>, or plainchildren. You built the mechanics (compound components with shared context) in Topic 10.5; here the point is the design choice: when the caller needs layout freedom, hand them pieces, not switches. - 4
Variants turn style choices into a small, closed set of named options:
variant="primary" | "secondary" | "danger"andsize="sm" | "md". A variant names an intent (danger), not a colour (red), so a redesign changes one map instead of every call site. Libraries such asclass-variance-authority(cva) and Tailwind Variants generate these class maps (Topic 11.3). - 5
Polymorphism means one component can render as different elements. The
asprop (<Button as="a" href="/docs">) swaps the tag while keeping the styles. The **asChild** idea (popularised by Radix UI'sSlot) goes further: instead of rendering its own element, the component merges its props and classes into its single child, so<Button asChild><Link to="/docs">Docs</Link></Button>produces one router link that looks like a button, with no wrapper element. Typingasproperly in TypeScript is covered in Topic 12.6. - 6
Escape hatches are the ways a caller can do something you didn't plan for. Three matter most: accept
classNameand merge it with yours (never replace yours); spread...restonto the root element soaria-*,data-*,id,titleand native event handlers pass through; and forwardrefto the root DOM node so callers can focus or measure it (in React 19refis an ordinary prop for function components, see Topic 19.8). Missing escape hatches are why teams fork components.
Explain it without notes
How would you design a component that can be both controlled and uncontrolled?
What does "composition over configuration" mean for component APIs? Give an example.
What is the difference between an as prop and the asChild pattern?
Which escape hatches should a design-system component offer, and why?
Practice
Write a Toggle (a switch button) that supports checked, defaultChecked and onCheckedChange, using useControllableState.
Turn this configuration API into a composition API: <Alert title="Saved" message="Your draft was saved." actionLabel="Undo" onAction={undo} />.
Trade-offs
- ↔
Supporting both controlled and uncontrolled modes doubles the behaviours to test, but it's what makes a component usable in both quick forms and complex screens.
- ↔
Composition APIs are flexible but give callers more ways to build something inconsistent; design systems balance them with a few well-named variants and documented recipes.
- ↔
Polymorphism (
as,asChild) is powerful but harder to type and can produce invalid HTML (a<button>inside an<a>); keep it to components where it's clearly needed.
Done when you can
I can name props consistently:
on…for events, HTML names for booleans.I can build a component that works both controlled and uncontrolled.
I can turn a configuration-heavy API into a composition API.
I can implement variants, an
asprop and theasChildslot idea.I can add the three escape hatches: merged
className,...restandref.