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, oronValueChange. - 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.
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, oronOpenChange. The component decides how a value turns into pixels. childrenis 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 asReactNode. Vue and web components have real named slots, and Svelte 5 has snippets, which do the same job.
title, so it is a string; it only places actions and the children, so those are nodes and can hold anything.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>
);
}
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.
| Input | Shape | Why |
|---|---|---|
| A dialog's title | string or node, required | It becomes the accessible name through aria-labelledby, so it must exist; a node is fine if it renders text. |
| A button's icon | slot (ReactNode) | The button only places it; any icon library works. |
| A button's size | enum prop ("sm" | "md" | "lg") | The button must map it to padding and font size from the design tokens. |
| A list's row content | children or a render function | Rows vary per screen; the list only manages layout and keyboard. |
| An input's error message | string prop | The 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: stringon a menu item means nobody can add a keyboard shortcut hint or a badge later without a new prop likelabelSuffix. - Nodes where strings belong.
placeholder: ReactNodelooks flexible, but aninputplaceholder can only be text, so the prop lies about what works. - Too many slots.
headerLeft,headerCenter,headerRight,subHeaderis 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 overchildrenand checkschild.type === MenuItembreaks 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.
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.
// 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" };
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.
// 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>
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?
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
<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>
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?".
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>
);
}
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
childrenwithReact.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
itemsas 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.
What callers write:
<Select
label="Plan"
items={plans}
defaultValue="free"
onChange={(e) => track(e.target.value)}
/>| Need | Works? | Why |
|---|---|---|
| Custom option content (badge, icon) | no | labels are strings; each new look is a new field |
| 5,000 options, virtualized | yes | data in, so the list can render only what is visible |
| Reset it or sync it to the URL | no | the parent can only remount it with a new key |
| Common case in one short call | yes | defaultValue and go |
| Trigger styled as a pill or a link | no | needs a new prop per look |
| Wire straight to a state setter | partly | callers 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(ordefaultOpen) and listen to changes withonValueChange, 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(oropen). The component never changes it alone. When the user acts, it callsonValueChange(next)and waits for the parent to pass a newvalue. 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.
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.
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;
}
undefinedmeans uncontrolled;nullis a real value. A select with nothing chosen isvalue={null}, which is still controlled. Mixing these up is the most common bug in hand-written versions.defaultValueis read once, on the first render, exactly likeuseState. Changing it later does nothing. If a caller needs to reset an uncontrolled component, they change itskeyor 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}beforeuserhas loaded:undefinedfirst, 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.
- Pick a plan, or use the parent's controls.
Naming that tells callers the mode
| State | Controlled prop | Uncontrolled prop | Change event |
|---|---|---|---|
| Selected value | value | defaultValue | onValueChange(value) |
| Open or closed | open | defaultOpen | onOpenChange(open) |
| Checked | checked | defaultChecked | onCheckedChange(checked) |
| Expanded items | expanded | defaultExpanded | onExpandedChange(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.
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.
// 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 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, andTabs, withx,defaultX,onXChangeprops and anasChildprop 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, anduseListBox, 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, andTabs, 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, andtdyourself, or a virtualized grid.
Decision Your company is starting a design system. Where should component behavior come from?
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.
<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>
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.
| Question | Render prop | Hook |
|---|---|---|
| Who renders the element | The component, calling your function | You, spreading returned props |
| Good for | Per-item state in a list the component owns | Sharing logic; full control of markup |
| Cost | A new function each render; nesting if used for logic | Caller must spread props onto the right element |
| Footgun | Heavy work inside the function runs for every item | Forgetting 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
asprop (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 withas, 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.
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.
// 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) => ...);
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.
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
onplus what happened, in the present tense:onOpenChange,onValueChange,onSelect,onDismiss. NothandleClose(that names the caller's function) and notclosed(that reads like a boolean).- Reserve native names for native meaning.
onChangeon a text field should behave like the DOMchange/inputevent React users expect: it receives an event object. If your callback receives a value instead, name itonValueChange. Mixing the two is a classic surprise. - Pair each state with one change event:
openwithonOpenChange(open), not separateonOpenandonClose. One callback is easier to wire to auseStatesetter and can't get out of step. AddonOpenAutoFocus-style detail events only where callers truly need them. - Name intent, not mechanics:
onDismissrather thanonEscapeKeyOrOutsideClick. 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.
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} />
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.
- Fire on user actions only, not on prop changes. If the parent sets
value="b", the component must not callonValueChange("b")back. Otherwise a parent that saves on change creates an infinite loop or a double save. - Don't fire when nothing changed. Clicking the already selected option fires nothing (the
Object.ischeck in the hook). - Fire once per action. Selecting with Enter must not fire for both the keydown and a synthetic click.
- 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
onInputChangefor raw text. - 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.
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.
| Change | Breaking? | Why |
|---|---|---|
| Add an optional prop | No | Existing calls keep working. |
| Rename or remove a prop | Yes | Existing calls stop compiling or silently lose behavior. |
| Make an optional prop required | Yes | Calls without it fail to compile. |
Add a value to a union prop (size: "xl") | Usually no | But callers with exhaustive switch statements on that type may fail to compile. |
| Change which element the ref points to | Yes | Callers that call .focus() or measure it get a different node. |
| Change the DOM structure or class names | Often | Tests, CSS overrides, and analytics selectors depend on them even if you never promised them. |
Fire onValueChange at a different time | Yes | Callers 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
- 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 settingtone="critical". - Mark the old one deprecated. Add a
@deprecatedJSDoc 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. - 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.
- 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.
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} />;
}
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
Dialogwithout a title, or anIconButtonwithout a label, is inaccessible. Maketitleoraria-labelrequired in the types so the mistake doesn't compile. - Generate ids, don't ask for them. Use
useIdinside 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-*anddata-*. Spread unknown props onto the main element so callers can addaria-describedbyor 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
Selectwith 5,000 options can't mount 5,000 JSX children cheaply. Anitemsarray 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.
- 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.
- Name the state and the mode. Say which state exists (
value,open,highlightedIndex), which is controllable (value,open) and which stays internal (highlightedIndex). Use thex,defaultX,onXChangenaming. - Choose composition on purpose. Say why: "options vary per screen, so I'll use compound parts with context and keep
itemsplus a render function for very large lists." - Cover the contract. When events fire, what the ref points to, the ARIA pattern, and keyboard support.
- Close with evolution. How you'd add features without breaking callers, and how you'd deprecate a prop.
| Level | What a strong answer shows |
|---|---|
| Junior | Sensible props and children, clear event names, a controlled value with onChange, basic keyboard support. |
| Mid | Compound components with context, both controlled and uncontrolled modes, slots vs props reasoning, useId and ARIA wiring, ref forwarding. |
| Senior | Composition 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?
First the simplest: <Autocomplete label="City" items={cities} onValueChange={setCity} />, uncontrolled, with a required label. It shows the common case is one line and that the accessible name is not optional.
Then the hard one: a controlled value and inputValue synced to the URL, async items loaded from a search API, and a custom option renderer showing a flag and population. It forces decisions about controlled state for two values (selection and typed text), loading and empty slots, and how option rendering stays accessible. The autocomplete guide walks through the rest of that design.
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
childrenis 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,onXChangenaming and oneuseControllableStatehook. - 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?
An uncontrolled component owns its state: the caller sets a starting value with defaultValue and listens with onValueChange. A controlled component shows the value prop and only asks for changes through onValueChange; the parent decides.
I support both with one hook: useControllableState treats value !== undefined as controlled, keeps internal state otherwise, fires the change event in both modes only for real user changes, reads defaultValue once, and warns in development if a component switches modes.
Question 2
When would you choose compound components over a single component with props?
When the component has regions or content that vary a lot between uses: tabs, selects, menus, dialogs, tables. Compound parts let callers arrange and customize the pieces in JSX while the root shares state through context and keeps keyboard, focus, and ARIA correct.
For a small leaf with a closed set of looks, like a badge, props are simpler. For widely shared components I build the compound parts first and add a configured preset on top for the common case.
Question 3
What is a headless component, and why would a design system use one?
It provides behavior, state, keyboard handling, and ARIA wiring without styles, either as unstyled compound components (Radix, Headless UI, React Aria Components) or as hooks that return props or data for your own markup (React Aria hooks, TanStack Table).
A design system wraps them to get proven accessibility while keeping full visual control. The wrapper's API is the contract, so the engine underneath can change later without breaking callers.
Question 4
How do you let a design system Button render as a link?
Two common ways: a polymorphic as prop (<Button as="a" href>), which is easy to use but hard to type, or asChild, where the button renders its child and merges its props, class names, handlers, and ref into it.
With asChild the child must spread props and pass the ref to a DOM element. Either way I'd document the supported elements and never allow non-interactive ones like div.
Question 5
How would you rename a widely used prop without breaking callers?
Add the new prop in a minor release and map the old one onto it, mark the old one @deprecated with a one-time development warning, ship a codemod and track remaining usage, then remove it in the next major with release notes.
I'd also treat timing, ref targets, and documented DOM hooks as part of the API, since changing those breaks callers even when the types don't change.
Practice it
These design problems lean on designing component apis.
- Design an accordionEasy
- Design a tabs componentEasy
- Design a tooltipEasy
- Design a star rating widgetEasy
- Design a toast notification systemEasy
- Design a modal dialogEasy
- Design a pagination controlEasy
- Design breadcrumbsEasy
- Design an OTP inputEasy
- Design a theme switcher with dark modeEasy
- Design a loading skeleton systemEasy
- Design a countdown timerEasy
- Design a signature padEasy
- Design an autocompleteMedium
- Design a custom select dropdownMedium
- Design an image carouselMedium
- Design a date pickerMedium
- Design an infinite scroll listMedium
- Design a file uploaderMedium
- Design a tag (multi-select) inputMedium
- Design a throttled progress bar queueMedium
- Design a file-explorer tree viewMedium
- Design a command paletteMedium
- Design a drag-and-drop reorderable listMedium
- Design an @-mention inputMedium
- Design a markdown editor with live previewMedium
- Design a multi-step form wizardMedium
- Design a color pickerMedium
- Design an avatar image cropperMedium
- Design an image lightbox with zoomMedium
- Design an Instagram-style stories viewerMedium
- Design a dropdown menuMedium
- Design a data tableHard
- Design a virtualized listHard
- Design a calendar week viewHard
- Design a Kanban boardHard
- Design a combobox for 100,000 optionsHard
- Design an embeddable code editorHard
- Design a drag-and-drop form builderHard
- Design a video playerHard
- Design a PDF viewerHard
- Design a design system for many teamsHard
References
- React: Passing data deeply with context The mechanism behind compound components.
- React: Sharing state between components (controlled and uncontrolled) The official explanation of the two modes.
- React 19 release notes: ref as a prop Why new components don't need forwardRef.
- Radix Primitives: Composition (asChild) How asChild merges props and refs into your element.
- React Aria Headless hooks and unstyled components from Adobe.
- TanStack Table: Introduction What a headless table engine provides and leaves to you.
- WAI-ARIA Authoring Practices Guide: Patterns The keyboard and ARIA behavior each widget's API must preserve.
Read next
- Component questions vs app questionsFrontend design prompts come in two shapes, a reusable component or a whole product, and each needs a different emphasis and a different use of your 45 minutes.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Local vs global stateWhere each piece of state lives decides what re-renders when it changes: keep it next to its readers, lift it only as far as needed, and use context or a store with selectors only for state that many distant parts share.
- Design systemsA shared set of design tokens, accessible components, patterns, docs, and rules that many teams build on, so a company ships one consistent product instead of many slightly different ones, and can change its look in one place.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.