Command Palette

Search for a command to run...

Hectal
PHASE 22Advanced ~28 min· topic 2 of 7

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 value prop; the parent owns the state and every change goes through onChange.
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 as prop.
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.

call sites (written first)whole filejsx
// 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.

shared/hooks/useControllableState.jswhole filejavascript
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];
}
Support both modes with one hookdiagram
Rendering diagram…

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).

Card: before and afterwhole filejsx
// 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.

shared/ui/Button.jsxwhole filejsx
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. 1

    Make Tickets controlled

    In the Stepper example, add const [tickets, setTickets] = useState(1); to App and change the Tickets stepper to value={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 no onChange.

  2. 2

    Add a variant

    Add ghost: "btn-ghost" to VARIANTS and a .btn-ghost { background: transparent; color: #0b7285; } rule, then render <Button variant="ghost">Cancel</Button>. One map entry, no new if.

  3. 3

    Break the className merge

    In Button, move className out of the destructuring so it stays in rest (remove it from the parameter list and from cx). Predict the Buy button's class list, then run. The caller's class replaced every design-system class.

Code & diagrams

One Stepper, used uncontrolled and controlled New tab

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

Press Run to see the page.

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 = 4
A Button API: variants, as, asChild, className, rest and ref New tab

Read 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

Press Run to see the page.

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: Save
The same API typed (TypeScript, see Topic 12.6)tsx
import 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.

terminal
$ Console
── what you'll see ──
A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://react.dev/link/controlled-components

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.

terminal
$ Preview
── what you'll see ──
Clicking + logs the analytics line, but the number never changes.

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 isControlled should not change during a component's life. Production hooks (Radix's useControllableState, React Aria's useControlledState) warn in development when a component switches modes, just like React does for <input>.

  • ▸

    Calling the parent's onChange inside a setState updater (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.

  • ▸

    cloneElement is marked as uncommon in React's docs because it makes data flow hard to follow and it can't see through wrapper components. asChild is one of the few well-accepted uses; React's docs suggest render props or context for most other cases.

  • ▸

    For TypeScript, ComponentProps<"button"> (or ComponentPropsWithRef) gives every native attribute for free, and a discriminated union with never makes "value or defaultValue, not both" a compile error rather than a runtime surprise.

Remember this

  1. 1

    Name props like the platform. Event callbacks start with on (onChange, onSelect, onOpenChange) and the code that handles them inside starts with handle. Booleans read as facts: disabled, open, required (match the HTML names when the HTML idea exists, rather than inventing isDisabled). 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. 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 passes value and onChange, owns the state, and can refuse or change any update. The usual rule: if value is not undefined, the component is controlled. A small hook, often called useControllableState, holds this logic once for every component in your design system.

  3. 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 of ifs. A composition API gives parts that callers arrange: <Card><Card.Header>…</Card.Header><Card.Body>…</Card.Body></Card>, or plain children. 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. 4

    Variants turn style choices into a small, closed set of named options: variant="primary" | "secondary" | "danger" and size="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 as class-variance-authority (cva) and Tailwind Variants generate these class maps (Topic 11.3).

  5. 5

    Polymorphism means one component can render as different elements. The as prop (<Button as="a" href="/docs">) swaps the tag while keeping the styles. The **asChild** idea (popularised by Radix UI's Slot) 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. Typing as properly in TypeScript is covered in Topic 12.6.

  6. 6

    Escape hatches are the ways a caller can do something you didn't plan for. Three matter most: accept className and merge it with yours (never replace yours); spread ...rest onto the root element so aria-*, data-*, id, title and native event handlers pass through; and forward ref to the root DOM node so callers can focus or measure it (in React 19 ref is an ordinary prop for function components, see Topic 19.8). Missing escape hatches are why teams fork components.

Explain it without notes

01

How would you design a component that can be both controlled and uncontrolled?

02

What does "composition over configuration" mean for component APIs? Give an example.

03

What is the difference between an as prop and the asChild pattern?

04

Which escape hatches should a design-system component offer, and why?

Practice

01

Write a Toggle (a switch button) that supports checked, defaultChecked and onCheckedChange, using useControllableState.

02

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 as prop and the asChild slot idea.

  • I can add the three escape hatches: merged className, ...rest and ref.