cf.completefrontend
My progress
FUNDAMENTALS · MODULE 17

Designing component APIs

How to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.

The problem: a Button with 40 props

Picture a shared Button in a company with 30 frontend engineers. Version one takes label, onClick, and variant. Then a product team needs a spinner, so it gets loading.

Another team wants the text to change while loading, so it gets loadingText. Then come iconLeft, iconRight, iconOnly, fullWidth, href, target, tooltip, and confirmMessage.

Two years later the Button has 40 props. Some of them fight: what does iconOnly with loadingText show? href with type="submit"? Nobody knows, because nobody tried.

The file is 600 lines of if statements, 300 screens use it, and every change breaks one of them.

Nothing in that story was a bad decision on its own. Each prop solved a real request in five minutes. The problem is the shape: every new need went through the component's author and became one more prop. That is the failure this lesson is about.

What a component API is

A component's API (application programming interface) is everything another engineer touches without opening the file. Code inside the component is private: you can rewrite it freely. The API is public: once 300 screens depend on it, changing it is expensive.

  • Props: plain values and callbacks, like size="sm", disabled, or onValueChange.
  • Children and slots: content the caller passes in, which the component places in its layout.
  • Events: the callbacks it fires, their names, their arguments, and exactly when they fire.
  • State ownership: whether the component keeps its own state or lets the caller own it (controlled or uncontrolled).
  • The ref and the DOM: which element a ref points to, which data-* attributes and class names are stable for styling and tests.
  • Behavior promises: keyboard support, focus handling, and ARIA roles. Callers rely on these even if no prop names them.
Two layers of a component. The top layer is public, and callers depend on it: props, which are values and callbacks; children and slots, content to place; events, with their names and timing; state ownership, controlled or not; the ref and DOM hooks such as data attributes; and behavior promises: keys, focus, and ARIA. The bottom layer is private and free to change: inner state like the highlighted item and timers, helpers like effects and utilities, and markup details like wrappers and layout.public: callers depend on itPropsvalues, callbacksChildren, slotscontent to placeEventsnames and timingState ownercontrolled or notRef and DOMnode, data-* hooksBehaviorkeys, focus, ARIAprivate: free to changeInner statehighlight, timersHelperseffects, utilitiesMarkup detailswrappers, layout
Everything in the top layer is a promise: changing it can break callers, even the parts no type checks, like event timing and keyboard behavior. The bottom layer is yours to rewrite.

Engineers read a component's API far more often than its code. A good API makes the common case one short line and the rare case possible without asking the author. A bad one makes the common case long, the rare case impossible, and every new case a pull request against the shared component.

Two ways a component can grow

When someone needs a new variation, there are two ways to give it to them. Configuration adds a prop: the component decides what to render, and the caller picks from a menu. Composition exposes parts: the caller assembles the pieces, and the component keeps the behavior that must stay right, like focus, keyboard, and ARIA.

Think of a set menu and a sandwich counter. A set menu is configuration: every change is a special request, and soon the order form has 40 checkboxes. A sandwich counter is composition: you pick bread, filling, and sauce from parts the kitchen keeps fresh, and you can make a sandwich nobody planned. The counter still controls quality; you control the combination.

Neither is always right. A Badge with a tone prop is fine as configuration forever. A Select, a Dialog, or a Table is used in so many ways that only composition keeps it small. The rest of this lesson is the toolbox for choosing, and for doing composition well: slots, compound components, controlled state, headless hooks, render props, polymorphism, refs, event contracts, and how to change an API without breaking 300 screens.

Props, children, and slots

Every component takes input in three shapes. Picking the right shape for each input is the first and most common API decision, and most prop explosions start with a wrong pick here.

The three shapes of input

  • Props are plain values: a string, a number, a boolean, an enum, a callback. Use them for data and small choices, like size="sm", disabled, or onOpenChange. The component decides how a value turns into pixels.
  • children is the content between the opening and closing tags. Use it for the main content, so callers can pass anything: plain text, an icon plus text, or a whole form. The component doesn't need to know what it is.
  • Slots are props that take elements instead of values, for content with a fixed place in the layout: icon, actions, footer, emptyState. React has no special slot syntax: a slot is a prop typed as ReactNode. Vue and web components have real named slots, and Svelte 5 has snippets, which do the same job.
One Card call site, line by line. The title prop, Invoices, is a value: the card turns it into the h2 heading. The actions prop holds an ExportButton element: a slot the card only places in its header. The InvoiceTable between the tags is children: the main content.<Cardtitle="Invoices"value: becomes the h2actions={<ExportButton />}slot: only placed><InvoiceTable rows={rows} />children: main content</Card>
Three shapes in one call. The card must understand title, so it is a string; it only places actions and the children, so those are nodes and can hold anything.
tsx
type CardProps = {
  title: string;               // a value: always text, styled by the card
  actions?: React.ReactNode;   // a slot: buttons, a menu, anything
  children: React.ReactNode;   // the main content
};

function Card({ title, actions, children }: CardProps) {
  return (
    <section className="card">
      <header className="card-header">
        <h2>{title}</h2>
        {actions ? <div className="card-actions">{actions}</div> : null}
      </header>
      <div className="card-body">{children}</div>
    </section>
  );
}
A card with one value prop, one slot, and children.

Notice what each choice buys. title is a string, so the card can guarantee it is a real heading with the right level and style. actions is a slot, so the card doesn't care whether it holds one button or a menu. children is open, so the body can be anything.

Value or node: how to decide

The question to ask is: does the component need to understand this input, or only place it? If it needs to understand it, make it a value. A Badge that picks a color from tone="danger" must understand the tone. An IconButton that puts its label into aria-label needs a string, because an attribute can only hold text.

If the component only places it, make it a node. An EmptyState that shows an illustration doesn't need to understand the illustration. Making it a slot means the next team with a new illustration doesn't need a new prop.

InputShapeWhy
A dialog's titlestring or node, requiredIt becomes the accessible name through aria-labelledby, so it must exist; a node is fine if it renders text.
A button's iconslot (ReactNode)The button only places it; any icon library works.
A button's sizeenum prop ("sm" | "md" | "lg")The button must map it to padding and font size from the design tokens.
A list's row contentchildren or a render functionRows vary per screen; the list only manages layout and keyboard.
An input's error messagestring propThe input must know an error exists to set aria-invalid, and it links the message with aria-describedby.

Mistakes with each shape

  • Strings where nodes belong. label: string on a menu item means nobody can add a keyboard shortcut hint or a badge later without a new prop like labelSuffix.
  • Nodes where strings belong. placeholder: ReactNode looks flexible, but an input placeholder can only be text, so the prop lies about what works.
  • Too many slots. headerLeft, headerCenter, headerRight, subHeader is configuration wearing a slot costume. If a region keeps growing slots, hand the whole region to the caller as a composed part instead (the next chapters).
  • Inspecting children. Code that loops over children and checks child.type === MenuItem breaks as soon as someone wraps an item in a fragment, a feature-flag wrapper, or their own component. Prefer context, covered in the compound components chapter.

Check your understanding · Exercise 1

You are designing a Toast. Which inputs should be plain value props rather than slots or children? Select all that apply.

Configuration explosion vs composition

The Button from chapter one is a case of boolean prop soup: many independent on/off props on one component. It feels cheap, because each prop is one line. The cost is hidden in the combinations.

Why booleans multiply

Each boolean doubles the number of states the component can be in. Six booleans give 2 to the power of 6, which is 64 combinations. Ten give 1,024. Nobody designs, tests, or even looks at most of them, but callers can reach every one, and some will.

A bar chart, drawn to scale, of how many prop combinations a component allows. 2 booleans: 4 states. 3 booleans: 8. 4 booleans: 16. 6 booleans: 64. 8 booleans: 256. 10 booleans: 1,024. The first bars are barely visible next to the last one.reachable states, to scale2 booleans43 booleans84 booleans166 booleans648 booleans25610 booleans1,024
Each boolean doubles the states callers can reach. Nobody designs or tests 1,024 combinations, but every one of them is allowed by the types.

Worse, many combinations are nonsense. iconOnly with loadingText, fullWidth with iconOnly, href with type="submit". The type system allows them, so the component needs runtime rules, and each rule is one more if that nobody remembers.

Make impossible states impossible

The first fix is in the types. Replace related booleans with one enum, and replace props that only make sense together with a discriminated union: a union of object types told apart by one field. TypeScript then rejects bad combinations at the call site, before anything runs.

tsx
// Before: 2 x 2 x 2 = 8 states, 4 of them invalid (two or more flags on)
type Before = { primary?: boolean; danger?: boolean; ghost?: boolean };

// After: exactly 4 states, all valid
type Variant = "primary" | "secondary" | "danger" | "ghost";

// A link needs href; a button must not have one
type ActionProps =
  | { as: "link"; href: string }
  | { as?: "button"; href?: never; type?: "button" | "submit" };
Three booleans become one enum; link-or-button becomes a union.

The real fix: give callers the parts

Enums tame the combinations you planned. They don't help with the ones you didn't. For those, stop adding props and let the caller compose. Instead of iconLeft, iconRight, and iconOnly, the button takes children, and the caller writes the icon where they want it.

tsx
// Configuration: every layout is a prop the author had to add
<Button iconLeft={<PlusIcon />} loading loadingText="Saving" fullWidth>
  Save
</Button>

// Composition: the caller arranges parts; the button keeps behavior
<Button busy={saving} className="w-full">
  <PlusIcon aria-hidden />
  {saving ? "Saving" : "Save"}
</Button>
Configuration on top, composition below: the second needs no new props for new layouts.

The composed version has fewer props and covers more cases. The button still owns what must stay correct: the disabled logic, aria-busy, focus styles, and the click guard while busy. The caller owns what varies: the icon, its position, and the text.

Decision A shared component keeps getting requests for new variations. How should it grow?

Configuration (more props)

Add a prop per request

The component renders every variation itself

Pros

  • Shortest call sites for the planned cases
  • Easy to enforce the design system's look
  • Simple to document as a props table

Cons

  • States multiply with every boolean
  • Every new need waits for the author
  • Props start to conflict; the file fills with special cases

Composition (parts and children)

Expose sub-components or slots

Callers assemble them; shared state flows through context

Pros

  • Covers cases nobody planned
  • Small, stable API surface
  • Each part is simple to test

Cons

  • Longer call sites for the common case
  • Callers can build layouts the designers never approved
  • More concepts to learn

Both: composed core, configured shortcut

Build the composable parts first

Ship a thin preset like SimpleSelect made from those parts

Pros

  • One-liner for the 80% case
  • Full control when needed without forking

Cons

  • Two APIs to document and keep in sync
  • Teams argue about which one to use

What we pick: Use configuration for small leaf components with a closed set of looks (Badge, Spinner, Avatar). Use composition for anything with regions or many uses (Dialog, Select, Menu, Table, Card). For widely shared components, build the composable parts and add a configured preset on top for the common case.

Check your understanding · Exercise 2

A Modal has showHeader, showFooter, showCloseButton, footerAlign, and secondaryButtonText. Which changes reduce the prop soup without losing behavior? Select all that apply.

Compound components

A compound component is a group of components that only make sense together and share hidden state: Tabs with Tabs.List, Tabs.Trigger, and Tabs.Panel, or Select with Select.Trigger and Select.Option. The caller arranges the parts like HTML. The parts talk to each other without the caller passing anything between them.

HTML already works this way. A select element and its option children share the selected value, and you never wire them together by hand. Compound components give your own widgets the same shape.

What it looks like to use

tsx
<Tabs defaultValue="billing">
  <Tabs.List aria-label="Account settings">
    <Tabs.Trigger value="profile">Profile</Tabs.Trigger>
    <Tabs.Trigger value="billing">
      Billing <Badge tone="warning">1</Badge>
    </Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="profile"><ProfileForm /></Tabs.Panel>
  <Tabs.Panel value="billing"><BillingForm /></Tabs.Panel>
</Tabs>
The caller controls order, layout, and content; the parts share the selected tab.

Compare the configured version: tabs={[{ id, label, content, badge }]}. It works until someone needs a badge, an icon, a tab that is a link, a panel that stays mounted, or a list placed beside the panels instead of above. Each of those is a new field. In the compound version they are just JSX.

How the parts share state

The root holds the state and puts it into a React context: a value that any descendant can read without props passing through every level. Each part reads the context. A Trigger asks "am I selected?" and "how do I select myself?"; a Panel asks "am I the visible one?".

A component tree for Tabs. The Tabs root holds the state, value billing, and provides a context with value, select, and baseId. Inside the context: Tabs.List, which has two triggers, profile with tabIndex minus 1 and billing with aria-selected true, and two panels: profile, hidden, and billing, visible. A note says: a click calls select billing, the value changes once, and every part re-reads it.Tabsvalue: billingTabsContext: value, select, baseIdTabs.ListPanel profilehiddenPanel billingvisibleprofiletabIndex -1billingaria-selectedclick: select("billing")value changes onceevery part re-reads it
The caller only writes the tree. The value lives once, in the root, so the selected trigger and the visible panel can never disagree, however the caller arranges or wraps the parts.
tsx
type TabsCtx = { value: string; select: (v: string) => void; baseId: string };
const Ctx = createContext<TabsCtx | null>(null);

function useTabs(part: string): TabsCtx {
  const ctx = useContext(Ctx);
  if (!ctx) throw new Error(`${part} must be inside <Tabs>`);
  return ctx;
}

function Tabs({ value, defaultValue, onValueChange, children }: TabsProps) {
  const [current, select] = useControllableState({ value, defaultValue, onChange: onValueChange });
  const baseId = useId();
  const ctx = useMemo(() => ({ value: current, select, baseId }), [current, select, baseId]);
  return <Ctx.Provider value={ctx}>{children}</Ctx.Provider>;
}

function Trigger({ value, children }: { value: string; children: ReactNode }) {
  const t = useTabs("Tabs.Trigger");
  const selected = t.value === value;
  return (
    <button role="tab" id={`${t.baseId}-t-${value}`} aria-selected={selected}
      aria-controls={`${t.baseId}-p-${value}`} tabIndex={selected ? 0 : -1}
      onClick={() => t.select(value)}>{children}</button>
  );
}
The core of a Tabs compound component, about 25 lines.

Four details carry most of the quality. The context default is null, and each part throws a clear error when used outside the root, instead of failing silently.

useId makes the ids that link a tab to its panel (aria-controls and aria-labelledby), so two Tabs on a page never collide. useMemo keeps the context value stable, so parts don't re-render when nothing they read changed. And useControllableState, from the next chapter, lets the same root work controlled or uncontrolled.

When parts need to know their order

Tabs can match parts by value. A Select or a Menu also needs order: arrow keys move to the next option, and typing "ca" jumps to the first option starting with "ca". The root must know the list of options in document order, even when options are wrapped in groups, fragments, or the caller's own components.

  • Walk children with React.Children.map. Simple, but it only sees direct children and breaks as soon as an option is wrapped. Avoid it for anything shared.
  • Register through context. Each option registers itself with the root on mount and unregisters on unmount. The root sorts registered items by DOM position (for example with compareDocumentPosition). This is the approach most headless libraries take, under names like "collections" or "descendants".
  • Pass data, render with a function. The root takes items as data and a render function for each item. Order is free, and it scales to virtualization, but you give up some JSX freedom. React Aria offers this as its collection API for dynamic lists.

The lab below lets you design a Select API by toggling the main choices from this lesson. Watch how the usage code, the flexibility, and the footguns change with each choice.

Options:
State:
Trigger:
Event:

What callers write:

<Select
  label="Plan"
  items={plans}
  defaultValue="free"
  onChange={(e) => track(e.target.value)}
/>
Flexibility: 2.5 of 6 common needs
NeedWorks?Why
Custom option content (badge, icon)nolabels are strings; each new look is a new field
5,000 options, virtualizedyesdata in, so the list can render only what is visible
Reset it or sync it to the URLnothe parent can only remount it with a new key
Common case in one short callyesdefaultValue and go
Trigger styled as a pill or a linknoneeds a new prop per look
Wire straight to a state setterpartlycallers unwrap e.target.value every time

Footguns to guard against:

  • Configuration creep: icon, badge, description, and disabledReason fields arrive one request at a time.
  • Parents can't reset or validate it, so callers start remounting it with key to clear a form.
  • A custom listbox has no native change event, so you invent a fake event object and callers expect the DOM one.

Check your understanding · Exercise 3

Your Menu finds its items by looping over children and checking child.type === MenuItem. Which caller code breaks keyboard navigation? Select all that apply.

Controlled vs uncontrolled: who owns the state

Every stateful component, like a select, tabs, a dialog, or an accordion, must answer one question: who owns the current value? The component itself, or the parent that renders it? The answer is the difference between uncontrolled and controlled, and a good shared component supports both.

The two modes

  • Uncontrolled: the component keeps its own state. The caller can set a starting value with defaultValue (or defaultOpen) and listen to changes with onValueChange, but never sets the value again. Easiest to use: one line, no state in the parent.
  • Controlled: the parent owns the state and passes it down as value (or open). The component never changes it alone. When the user acts, it calls onValueChange(next) and waits for the parent to pass a new value. More code for the caller, but full power: reset the value, validate it, sync it to the URL, or refuse a change.

Native form elements already work this way, and the Forms at scale lesson covers the form side in depth. This chapter is about building your own components that offer both modes with one implementation.

A sequence diagram with three columns: User, Select, and Parent. Uncontrolled: the user clicks Pro, the Select updates itself, then sends onValueChange to the parent as a notice. Controlled: the user clicks Pro, the Select sends onValueChange to the parent as a request, the parent calls setPlan pro, passes value pro back down, and only then does the Select render Pro.UserSelectParentuncontrolledclick Proupdates itselfonValueChange (notice)controlledclick ProonValueChange (request)setPlan("pro")value="pro"renders Pro
The same click, two owners. Uncontrolled, the Select changes first and tells the parent after. Controlled, nothing changes until the parent answers with a new value, so the parent can also say no.

Read the controlled sequence carefully: the click doesn't change anything by itself. It produces a request (onValueChange("b")), and the parent decides. If the parent ignores it, the select stays on "a". That is what makes controlled components predictable: there is exactly one source of truth.

One hook for both: useControllableState

Writing both modes into every component is error-prone. Radix and Chakra UI both build on a small shared hook for it called useControllableState (Radix names its options prop and defaultProp). Here is a complete version.

ts
type Options<T> = { value?: T; defaultValue: T; onChange?: (next: T) => void };

export function useControllableState<T>({ value, defaultValue, onChange }: Options<T>) {
  const [inner, setInner] = useState<T>(defaultValue);
  const isControlled = value !== undefined;
  const current = isControlled ? (value as T) : inner;

  const wasControlled = useRef(isControlled);
  useEffect(() => {
    if (wasControlled.current !== isControlled) {
      console.warn("Switched between controlled and uncontrolled. Pick one for the component's life.");
      wasControlled.current = isControlled;
    }
  });

  const setValue = useCallback((next: T) => {
    if (Object.is(next, current)) return;  // no change, no event
    if (!isControlled) setInner(next);      // uncontrolled: update ourselves
    onChange?.(next);                       // both modes: tell the caller
  }, [current, isControlled, onChange]);

  return [current, setValue] as const;
}
Tested in node: uncontrolled updates, controlled refusal, same-value skip, defaultValue read once, the switch warning, and null as a real value.
  • undefined means uncontrolled; null is a real value. A select with nothing chosen is value={null}, which is still controlled. Mixing these up is the most common bug in hand-written versions.
  • defaultValue is read once, on the first render, exactly like useState. Changing it later does nothing. If a caller needs to reset an uncontrolled component, they change its key or switch to controlled.
  • The event fires in both modes, and only for real changes. Callers in uncontrolled mode still need to know about changes, for analytics or for saving a draft.
  • Switching modes is a bug, so it warns in development. It usually happens when a parent passes value={user.plan} before user has loaded: undefined first, then a string. React warns about the same thing for native inputs.

The setter's identity changes when the value changes, which is fine for most components. Libraries that need a stable setter keep the latest props in a ref and read them inside one stable callback; the behavior is the same. Try both modes in the demo below, including a parent that refuses some changes.

PlanPicker is
Parent:
PlanPicker:
The parent page above the PlanPicker. The picker is uncontrolled. The picker holds the plan itself; the parent passed defaultValue once and hears onValueChange only as a notice afterwards.Parent pagedoesn't hold the planPlanPickerholds its own plandefaultValueread onceonValueChangea notice
Uncontrolled: the picker decides alone. The parent can listen, but to reset it must remount it with a new key.
  • Pick a plan, or use the parent's controls.

Naming that tells callers the mode

StateControlled propUncontrolled propChange event
Selected valuevaluedefaultValueonValueChange(value)
Open or closedopendefaultOpenonOpenChange(open)
CheckedcheckeddefaultCheckedonCheckedChange(checked)
Expanded itemsexpandeddefaultExpandedonExpandedChange(ids)

The pattern is x, defaultX, onXChange. Callers learn it once and can guess every component. Radix, React Aria, and Headless UI all follow some version of it, which is a good reason for your design system to follow it too.

Check your understanding · Exercise 4

A caller writes <Dialog open={isOpen}> but never passes onOpenChange. What happens when the user presses Escape? Select all that apply.

Headless components: behavior without looks

A headless component gives you behavior, state, keyboard support, and ARIA wiring, but no styles. You bring the markup and the CSS. It splits the two halves of a widget that change for very different reasons: how it looks changes with every brand and redesign; how a listbox handles arrow keys and screen readers almost never changes.

Why split behavior from looks

A correct combobox is hundreds of lines of careful logic: aria-activedescendant, focus staying in the input, typeahead, Home and End, scrolling the active option into view, closing on blur but not on a click inside the list, screen reader announcements, and touch quirks. Getting that right once is hard. Getting it right again for every visual design is wasteful.

Styled component libraries bundle the two halves, so a team that needs a different look ends up overriding CSS for years or forking the component. Headless libraries let a design system put its own skin on proven behavior.

A matrix of five widget layers against four approaches. Styled libraries such as MUI and Chakra provide all five: keyboard and focus, ARIA wiring, state, markup, and styles. Unstyled component libraries such as Radix provide everything except styles. Hook libraries such as React Aria hooks provide keyboard and focus, ARIA, and state; you write markup and styles. Doing it yourself means you write all five.StyledMUI, ChakraUnstyledRadixHooksReact AriaDIYyour teamKeyboard, focuslibrarylibrarylibraryyouARIA wiringlibrarylibrarylibraryyouStatelibrarylibrarylibraryyouMarkuplibrarylibraryyouyouStyleslibraryyouyouyou
Headless approaches keep the hard, rarely changing layers (keyboard, ARIA, state) in a tested library and hand you the layers that change with every brand. The further right, the more control and the more work.

Two forms: unstyled components and hooks

Headless behavior comes in two shapes. Unstyled components are compound components that render real DOM elements with no styles: you style them with classes and the data-state attributes they expose. Hooks return props for you to spread onto your own elements: you write all the markup.

tsx
// Unstyled components (Radix style): parts render elements; you add classes
<Popover.Root>
  <Popover.Trigger className="btn">Filters</Popover.Trigger>
  <Popover.Content className="panel data-[state=open]:fade-in">
    ...
  </Popover.Content>
</Popover.Root>

// Hooks (React Aria style): you write the element, the hook supplies behavior
function MyButton(props: AriaButtonProps) {
  const ref = useRef<HTMLButtonElement>(null);
  const { buttonProps } = useButton(props, ref);
  return <button {...buttonProps} ref={ref} className="btn">{props.children}</button>;
}
The same idea in both shapes. Simplified: check each library's docs for exact names.

The libraries you will hear about

These change often, so treat this as a map, not a buying guide, and check current docs before you name versions in an interview.

  • Radix Primitives (now maintained by WorkOS): unstyled compound components like Dialog, DropdownMenu, Popover, and Tabs, with x, defaultX, onXChange props and an asChild prop for using your own element. shadcn/ui popularized it further: it copies styled versions of Radix parts into your repository instead of installing a styled package.
  • React Aria (from Adobe): two layers. Low-level hooks like useButton, useComboBox, and useListBox, which return props for your elements, and React Aria Components, unstyled compound components built on those hooks. Known for deep internationalization and careful touch and screen reader handling.
  • Headless UI (from Tailwind Labs): a smaller set of unstyled components for React and Vue, like Menu, Listbox, Combobox, Dialog, and Tabs, designed to pair with Tailwind classes.
  • TanStack Table: a headless table engine. You give it columns and data; it returns row models with sorting, filtering, grouping, and pagination applied, plus handlers such as a header's sort toggle. It renders nothing: you write every table, tr, and td yourself, or a virtualized grid.

Decision Your company is starting a design system. Where should component behavior come from?

A styled library (MUI, Chakra, Ant Design)

Install it and theme it with tokens

Override styles where the theme can't reach

Pros

  • Fastest start: dozens of finished components
  • Behavior and accessibility already tested

Cons

  • A custom brand fights the library's CSS
  • Bundle includes the library's styling system
  • Upgrades can change looks you depend on

Headless primitives plus your own styles

Wrap Radix, React Aria, or Headless UI parts

Style them with your tokens; export your own API

Pros

  • Full visual control with proven behavior
  • Your API can stay stable if you swap the engine later

Cons

  • You still build and maintain every styled component
  • Two layers to learn and debug

Build everything yourself

Write behavior from the WAI-ARIA Authoring Practices

Test with keyboard and screen readers yourself

Pros

  • No dependency; exactly the features you need

Cons

  • Months of work to match the edge cases libraries already handle
  • Accessibility bugs are likely and hard to spot

What we pick: For a design system with its own brand, wrap headless primitives and expose your own API on top: the wrapper is the contract, and the primitive is an implementation detail. For an internal tool where looks matter less than speed, a styled library is fine. Build behavior yourself only for widgets no library covers, and test them against the ARIA patterns.

Check your understanding · Exercise 5

Which of these are the headless layer's job rather than the styling layer's? Select all that apply.

Render props, hooks, polymorphism, and refs

Composition has a few more tools. Each answers one specific question: how does the caller get at internal state, how does the caller choose which element renders, and how does the caller reach the DOM node.

Render props: share state into the caller's markup

A render prop is a function prop that the component calls to render part of its output, passing internal state as arguments. It lets the caller render anything while still seeing state only the component knows, like which option is active.

tsx
<Select.Option value="pro">
  {({ selected, active }) => (
    <div className={active ? "row row-active" : "row"}>
      {selected ? <CheckIcon aria-hidden /> : null}
      Pro plan <small>$12 a month</small>
    </div>
  )}
</Select.Option>
The option renders its own state; the caller decides what that looks like.

Before hooks (React 16.8, 2019), render props and higher-order components were the main ways to share logic between components, which led to deep nesting nicknamed "wrapper hell". Hooks replaced that use: a hook shares logic without adding a layer to the tree.

Render props remain the right tool for one job: giving the caller per-item state inside a list the component renders, like selected and active above. Headless UI uses this style (its options receive state such as selected and focus), and it also exposes the same state as data-* attributes, so you can often style without a function at all.

QuestionRender propHook
Who renders the elementThe component, calling your functionYou, spreading returned props
Good forPer-item state in a list the component ownsSharing logic; full control of markup
CostA new function each render; nesting if used for logicCaller must spread props onto the right element
FootgunHeavy work inside the function runs for every itemForgetting to spread ref or a handler breaks behavior silently

Choosing the element: the as prop and asChild

Often the styling is right but the element is wrong. A design system Button must sometimes be a link (a with href) or a router Link. Rendering a button inside an a is invalid HTML and confuses assistive technology, so you need a way to change the element.

  • The as prop (polymorphic component): <Button as="a" href="/pricing"> or <Text as="h2">. The component renders whatever element or component you name and passes the remaining props through. Simple to use, but typing it correctly in TypeScript is notoriously hard: props must change with as, and the generic types get slow and confusing.
  • asChild (the slot pattern Radix made popular): <Button asChild><Link href="/pricing">Pricing</Link></Button>. The component renders no element of its own. It clones its single child and merges its props into it: class names are joined, event handlers are chained (the child's handler runs first, then the component's), and refs are combined. Types stay simple, because the child keeps its own props.
Three columns. The trigger with asChild brings className btn, onClick toggle, ref triggerRef, and aria-expanded. Its child, a Link, has className nav, onClick track, ref linkRef, and href /pricing. The single rendered a element gets class btn nav, both click handlers chained so the child's track runs before toggle, both refs set, and the href plus the aria attributes.Trigger asChildclassName="btn"onClick={toggle}ref={triggerRef}aria-expandedchild: LinkclassName="nav"onClick={track}ref={linkRef}href="/pricing"rendered aclass="btn nav"track, then toggleboth refs sethref and aria-*+=
asChild renders no element of its own. It merges into the child: classes join, handlers chain with the child's first, refs combine. If the child component drops unknown props or its ref, the trigger silently stops working.

Both have footguns. With as, a caller can write <Button as="div"> and lose keyboard and focus support. With asChild, the child must accept and spread the props it receives, including the ref; a custom component that drops them breaks the trigger without an error. Whichever you choose, document which elements are supported.

Refs: reaching the DOM node

Callers need the real DOM node of your component more often than you expect: to focus an input after an error, to measure a trigger for a popover, to scroll an item into view, or for a headless library's asChild to attach behavior. A shared component that swallows the ref blocks all of that.

tsx
// React 19 and later
function TextField({ label, ref, ...rest }: TextFieldProps & { ref?: Ref<HTMLInputElement> }) {
  const id = useId();
  return (
    <div className="field">
      <label htmlFor={id}>{label}</label>
      <input id={id} ref={ref} {...rest} />
    </div>
  );
}

// React 18 and earlier needed a wrapper:
// const TextField = forwardRef<HTMLInputElement, TextFieldProps>((props, ref) => ...);
React 19: ref is an ordinary prop on function components.

Since React 19, function components receive ref as a normal prop, and the React team has said forwardRef will be deprecated in a future version. Libraries that still support React 18 keep forwardRef, which continues to work in 19. The design decision is the same either way: forward the ref to the element a caller would reasonably want, usually the interactive one (the input, not the wrapper div), and say so in the docs.

A decision tree. First question: does the content vary a lot per use? No: use props and enums. Yes: do several parts share state? No: use children and slots. Yes: must the caller write all the markup? No: use compound parts. Yes: use a headless hook. A side note says: if a list needs per-item state, add a render prop.varies a lot per use?props, enumsparts share state?noyeschildren, slotscaller writes markup?noyescompound partsheadless hooknoyesper-item state in a list?add a render prop
Start with the simplest tool that covers real uses and move right only when callers need it. Render props are an add-on for per-item state, not a starting point.

Check your understanding · Exercise 6

A caller writes <Tooltip.Trigger asChild><IconButton icon="trash" /></Tooltip.Trigger> and the tooltip never appears. What are likely causes? Select all that apply.

Events and onChange contracts

Callbacks are the half of an API that tells the caller what happened. A prop name is a promise about type; an event is a promise about timing: exactly when it fires, how often, and with what arguments. Vague events are where many integration bugs come from.

Naming events

  • on plus what happened, in the present tense: onOpenChange, onValueChange, onSelect, onDismiss. Not handleClose (that names the caller's function) and not closed (that reads like a boolean).
  • Reserve native names for native meaning. onChange on a text field should behave like the DOM change/input event React users expect: it receives an event object. If your callback receives a value instead, name it onValueChange. Mixing the two is a classic surprise.
  • Pair each state with one change event: open with onOpenChange(open), not separate onOpen and onClose. One callback is easier to wire to a useState setter and can't get out of step. Add onOpenAutoFocus-style detail events only where callers truly need them.
  • Name intent, not mechanics: onDismiss rather than onEscapeKeyOrOutsideClick. If callers need to tell causes apart, pass the reason as data.

What the callback receives

Pass the new value first, because that's what 90% of callers want: onValueChange={setPlan} should just work. Put anything else in a second details object, so you can add fields later without breaking anyone.

ts
type ChangeDetails = {
  reason: "click" | "keyboard" | "typeahead" | "clear";
  previous: string | null;
};

type SelectProps = {
  value?: string | null;
  defaultValue?: string | null;
  onValueChange?: (value: string | null, details: ChangeDetails) => void;
};

// Simple callers ignore details:
// <Select value={plan} onValueChange={setPlan} />
Value first, details second: a contract you can grow without breaking callers.

When it fires: the contract

Write the timing rules down and test them, because callers will depend on every one of them whether you meant it or not. These are the rules most mature libraries converge on.

  1. Fire on user actions only, not on prop changes. If the parent sets value="b", the component must not call onValueChange("b") back. Otherwise a parent that saves on change creates an infinite loop or a double save.
  2. Don't fire when nothing changed. Clicking the already selected option fires nothing (the Object.is check in the hook).
  3. Fire once per action. Selecting with Enter must not fire for both the keydown and a synthetic click.
  4. Fire after the action is valid, never with half-finished values. A date field fires when the date is complete and valid, not on every digit, or it offers a separate onInputChange for raw text.
  5. Fire synchronously in the event handler, not in an effect after render. An effect runs after the handler has finished and the state has already changed, so callers can no longer cancel anything, and the change arrives a render later than the action that caused it.
A timeline of one update in four phases: event handler, render, commit, effects. Good: onValueChange fires inside the event handler, while the caller can still cancel. Bad: onValueChange fired from an effect runs after commit, when the event is over. Also bad: when the parent changes value, the same effect fires onValueChange again, echoing a change the user never made.event handlerrendercommiteffectsphasesgoodonValueChange, still cancellableeffectfires after the event is overechoparent set value; the effect still fires onValueChangetime in one update
Fire change events from the user's event handler. An effect that watches the value fires late, fires on mount, and echoes changes the parent made itself.

Letting callers say no

Sometimes a caller needs to stop the default behavior: keep a dialog open while a form has unsaved changes, or keep a menu open after an item is chosen. Two patterns work. In controlled mode, the caller simply refuses: it receives onOpenChange(false) and keeps open true. For uncontrolled use, some libraries pass an event the caller can cancel, like Radix's onEscapeKeyDown(event) where event.preventDefault() stops the close.

Check your understanding · Exercise 7

A Combobox calls onValueChange inside useEffect(() => onValueChange(value), [value]). Which bugs will callers see? Select all that apply.

Changing an API without breaking 300 screens

Every API you ship will need to change. A prop name turns out confusing, a boolean needs a third option, a pattern from 2021 is replaced by a better one. The skill is changing it without a weekend of broken screens. Treat a shared component like a public library, even if every caller is in your own company.

What counts as a breaking change

Semantic versioning (semver) numbers releases as major.minor.patch: a patch fixes bugs, a minor adds features, and a major may break callers. The hard part is knowing what "break" means for a component. It is more than the TypeScript types.

ChangeBreaking?Why
Add an optional propNoExisting calls keep working.
Rename or remove a propYesExisting calls stop compiling or silently lose behavior.
Make an optional prop requiredYesCalls without it fail to compile.
Add a value to a union prop (size: "xl")Usually noBut callers with exhaustive switch statements on that type may fail to compile.
Change which element the ref points toYesCallers that call .focus() or measure it get a different node.
Change the DOM structure or class namesOftenTests, CSS overrides, and analytics selectors depend on them even if you never promised them.
Fire onValueChange at a different timeYesCallers depend on timing as much as on types.

The last three rows are the dangerous ones, because no type checker catches them. This is why many design systems publish an explicit list of stable hooks (data-* attributes, part names, CSS custom properties) and say that everything else in the DOM may change.

A deprecation in four steps

A bar chart of an example migration: call sites still using the deprecated variant prop, by release. v5.2 adds the new tone prop: 300 call sites. v5.3 adds a development warning: 280. v5.4 ships a codemod: 40. v5.5 cleans up stragglers by hand: 6. v6.0 removes the old prop: 0.call sites still using variant (example numbers)v5.2: add tone300v5.3: warning280v5.4: codemod40v5.5: cleanup6v6.0: removed0
Illustrative numbers. In a typical migration the warning moves few callers and the codemod does most of the work. Removal is safe only once usage reaches zero, which you measure, not guess.
  1. Add the new API next to the old one in a minor release. Both work. Internally, map the old prop onto the new one so there is only one code path: variant="danger" keeps working by setting tone="critical".
  2. Mark the old one deprecated. Add a @deprecated JSDoc tag with what to use instead, so editors strike it through. Log a development-only warning once per component, not once per render, with a link to the migration notes.
  3. Migrate callers automatically. A codemod is a script that rewrites source code using its syntax tree, with tools like jscodeshift or ts-morph. Renaming a prop in 300 files by hand is a week; a codemod plus review is an afternoon. Measure remaining usage by searching the codebase or with lint rules.
  4. Remove it in the next major, with release notes that link the codemod. Give callers a clear date, usually one or two minor cycles after the warning.
tsx
type BannerProps = {
  tone?: "info" | "success" | "critical";
  /** @deprecated Use `tone="critical"`. Removed in v6. */
  variant?: "danger";
  children?: React.ReactNode;
};

let warned = false;
function Banner({ tone, variant, ...rest }: BannerProps) {
  if (process.env.NODE_ENV !== "production" && variant && !warned) {
    warned = true;
    console.warn("Banner: `variant` is deprecated, use `tone`. See /docs/banner#migrate");
  }
  const finalTone = tone ?? (variant === "danger" ? "critical" : "info");
  return <div role="status" data-tone={finalTone} {...rest} />;
}
Old and new props side by side during the deprecation window.

How large libraries handle it

Large component libraries do this at scale. MUI ships codemods with its major versions so teams can migrate prop renames automatically. React itself warns in development for a version or more before removing an API, and published codemods for many of the React 19 changes. GitHub's Primer design system labels each component with a lifecycle status (experimental, alpha, beta, stable, deprecated) so teams know how much it may still change before they depend on it.

Check your understanding · Exercise 8

Which changes to a shared Select should be released as a major version? Select all that apply.

Accessibility, performance, and the interview

API decisions are also accessibility and performance decisions. Interviewers notice when you connect them, so this last chapter covers both angles and then how to use the whole toolbox in a component design round.

Accessibility through the API

  • Require the accessible name. A Dialog without a title, or an IconButton without a label, is inaccessible. Make title or aria-label required in the types so the mistake doesn't compile.
  • Generate ids, don't ask for them. Use useId inside the component to link labels, descriptions, and controlled regions. Asking callers for ids invites duplicates.
  • Let behavior own keyboard and focus. Arrow keys, Escape, focus trap, and focus return belong to the component, not to each caller. The ARIA widget patterns lesson lists what each widget needs.
  • Pass through aria-* and data-*. Spread unknown props onto the main element so callers can add aria-describedby or test ids without a new prop.

Performance through the API

  • Memoize context values, as in the Tabs example. An unmemoized object re-renders every part on every parent render. For large compound components, split the context into a rarely changing one (actions) and a frequently changing one (the value).
  • Data plus render function for large lists. A Select with 5,000 options can't mount 5,000 JSX children cheaply. An items array with a render function lets the component render only the visible rows (virtualize), which JSX children make hard.
  • Keep render props cheap. They run for every item on every render; push heavy work into memoized children.

Using it in a component design interview

Component questions ("Design a select", "Design a modal", "Design a data table") are mostly API questions. The Component questions vs app questions lesson covers how the round flows; here is where API design fits in it.

  1. Write usage first. Before any internals, write two call sites: the simplest one-line use, and a hard one (custom option rendering, controlled from the URL). This shows you design from the caller's side.
  2. Name the state and the mode. Say which state exists (value, open, highlightedIndex), which is controllable (value, open) and which stays internal (highlightedIndex). Use the x, defaultX, onXChange naming.
  3. Choose composition on purpose. Say why: "options vary per screen, so I'll use compound parts with context and keep items plus a render function for very large lists."
  4. Cover the contract. When events fire, what the ref points to, the ARIA pattern, and keyboard support.
  5. Close with evolution. How you'd add features without breaking callers, and how you'd deprecate a prop.
LevelWhat a strong answer shows
JuniorSensible props and children, clear event names, a controlled value with onChange, basic keyboard support.
MidCompound components with context, both controlled and uncontrolled modes, slots vs props reasoning, useId and ARIA wiring, ref forwarding.
SeniorComposition vs configuration trade-offs with a preset layer, headless split, event timing contracts, typing (unions, polymorphism or asChild), large-list performance, versioning and codemods.

Think first

You are asked to design an autocomplete. What would your first two usage examples look like, and why those two?

Summary

  • A component's API is everything callers touch: props, children and slots, events and their timing, state ownership, the ref, and behavior promises like keyboard and ARIA.
  • Use values for inputs the component must understand and nodes (slots, children) for inputs it only places. Inspecting children is fragile; share state through context.
  • Boolean props multiply states: replace related booleans with enums and unions, and hand varying regions to the caller through composition.
  • Compound components share state through a memoized context and fail loudly when a part is used outside its root.
  • Support controlled and uncontrolled modes with the x, defaultX, onXChange naming and one useControllableState hook.
  • Headless libraries separate behavior from looks; wrap them behind your own API so the engine can change.
  • Events are timing contracts: value first, details second, user actions only, once per action, never from an effect.
  • Change APIs in steps: add, deprecate with warnings and a codemod, then remove in a major.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What is the difference between a controlled and an uncontrolled component, and how do you support both?

Question 2

When would you choose compound components over a single component with props?

Question 3

What is a headless component, and why would a design system use one?

Question 4

How do you let a design system Button render as a link?

Question 5

How would you rename a widely used prop without breaking callers?

Practice it

These design problems lean on designing component apis.

References

Read next