Component API Design
2026-08-10 · 7 min read
A component's first version is easy. An autocomplete starts with query and results props and an onSelect callback. Then a consumer needs custom result rendering. Another needs debounce control. A third needs the same logic but wants a chip input instead of a dropdown. Each new requirement bends the API until it becomes a long flat list of props, half of them contradictory, none of them composable.
Good component API design anticipates this. The patterns below aren't abstractions for their own sake — each one exists because a naive prop list fails in a specific way.
Data, not behavior
The most common early mistake is passing behavior as props.
// Don't do this
<DataTable onFetchRows={fetchFromApi} onSort={sortOnServer} />
// Do this
<DataTable rows={rows} sortKey={sortKey} sortDir={sortDir} onSortChange={handleSort} />
The component accepts data and emits events. The consumer decides what to do with those events — fetch from an API, sort locally, log to analytics, or all three. A component that accepts onFetchItems can only work one way. A component that accepts items works with any data source.
The rule: props in, events out. The component's job is rendering and interaction, not orchestration.
Controlled vs. uncontrolled
React's own inputs model this well. An uncontrolled input owns its value internally; a controlled input defers to its parent.
// Uncontrolled: component owns the state
<Combobox defaultValue="en" />
// Controlled: parent owns the state
<Combobox value={locale} onChange={setLocale} />
Support both. defaultValue is the escape hatch for consumers who don't need to sync the component's state with anything external. value + onChange is for consumers who do. Internally, one implementation handles both:
function Combobox({ value, defaultValue, onChange }: ComboboxProps) {
const [internalValue, setInternalValue] = useState(defaultValue ?? '');
const isControlled = value !== undefined;
const currentValue = isControlled ? value : internalValue;
function handleChange(next: string) {
if (!isControlled) setInternalValue(next);
onChange?.(next);
}
// ...
}
The pattern shows up in any component that holds selection or input state: dropdowns, date pickers, toggle groups, carousels.
Render props for customizable output
When consumers need to control how something is rendered — not just styled, but structurally different — render props give them that control without the component needing to know anything about their UI.
type AutocompleteProps = {
apiUrl: string;
debounceMs?: number;
minQueryLength?: number;
maxResults?: number;
renderItem: (item: Result, isHighlighted: boolean) => React.ReactNode;
onSelect: (item: Result) => void;
onInputChange?: (value: string) => void;
className?: string;
};
renderItem is the escape valve. The component handles fetching, debouncing, keyboard navigation, and highlight tracking. The consumer handles what each result actually looks like — plain text, a user avatar with metadata, a rich preview card. The component doesn't care.
The tradeoff: render props shift effort to the consumer. For a simple case, renderItem={item => item.name} is fine. For a complex one, it puts the consumer in full control. That's usually the right tradeoff for components that need to work across different product surfaces.
The controller pattern
Render props solve the rendering problem. The controller pattern solves the logic problem: the same stateful behavior needs to power multiple, structurally different UIs.
Split the component into a controller (the brain) and a presenter (the face).
// The controller: manages all state and side effects
function useAutocomplete({ apiUrl, debounceMs = 300, minQueryLength = 2 }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const debouncedQuery = useDebounce(query, debounceMs);
useEffect(() => {
if (debouncedQuery.length < minQueryLength) {
setResults([]);
return;
}
setIsLoading(true);
fetchResults(apiUrl, debouncedQuery)
.then(setResults)
.finally(() => setIsLoading(false));
}, [debouncedQuery]);
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') setActiveIndex(i => Math.min(i + 1, results.length - 1));
if (e.key === 'ArrowUp') setActiveIndex(i => Math.max(i - 1, 0));
if (e.key === 'Escape') setResults([]);
}
return { query, setQuery, results, activeIndex, isLoading, handleKeyDown };
}
The hook knows nothing about rendering. It can power a standard dropdown, a full-page search overlay, a chip tag input, or a command palette — all with the same debouncing and keyboard logic.
// Standard dropdown UI
function AutocompleteDropdown(props) {
const { query, setQuery, results, activeIndex, handleKeyDown } = useAutocomplete(props);
return (
<div onKeyDown={handleKeyDown}>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>{results.map((r, i) => <li className={i === activeIndex ? 'highlighted' : ''}>{r.name}</li>)}</ul>
</div>
);
}
// Command palette UI — same controller, completely different structure
function CommandPalette(props) {
const { query, setQuery, results, activeIndex, handleKeyDown } = useAutocomplete(props);
return (
<Modal onKeyDown={handleKeyDown}>
<SearchInput value={query} onChange={e => setQuery(e.target.value)} />
<ResultGrid results={results} highlightedIndex={activeIndex} />
</Modal>
);
}
The controller pattern is the right choice when the behavior is complex and reusable, and different product contexts need that behavior wrapped in visually distinct shells.
Compound components
Some components are really groups of related components — a dropdown is a trigger plus a menu plus items. Forcing all that configuration through a single component's props produces unmaintainable prop lists:
// This doesn't scale
<Dropdown
trigger="Options"
items={[{ label: 'Edit', value: 'edit' }, { label: 'Delete', value: 'delete' }]}
onSelect={handleSelect}
itemClassName="..."
menuClassName="..."
triggerClassName="..."
/>
Compound components let the consumer control the structure while the library manages the behavior:
<Dropdown onSelect={handleSelect}>
<Dropdown.Trigger>Options</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item value="edit">Edit</Dropdown.Item>
<Dropdown.Item value="delete" disabled>Delete</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
Each sub-component is a real component with its own props. Dropdown.Item can accept disabled, icon, shortcut, or anything else without touching the parent's API. The structure is declarative and readable. Consumers can reorder items, wrap them in conditionals, or inject separators without special props for each case.
Shared state (open/close, active index) lives in a React Context the parent owns and the children read:
const DropdownContext = createContext<DropdownContextValue | null>(null);
function Dropdown({ children, onSelect }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
return (
<DropdownContext.Provider value={{ isOpen, setIsOpen, activeIndex, setActiveIndex, onSelect }}>
<div>{children}</div>
</DropdownContext.Provider>
);
}
Dropdown.Trigger = function Trigger({ children }) {
const { setIsOpen } = useContext(DropdownContext)!;
return <button onClick={() => setIsOpen(o => !o)}>{children}</button>;
};
Dropdown.Item = function Item({ value, children, disabled }) {
const { onSelect, setIsOpen } = useContext(DropdownContext)!;
return (
<li
role="menuitem"
aria-disabled={disabled}
onClick={() => { if (!disabled) { onSelect(value); setIsOpen(false); } }}
>
{children}
</li>
);
};
Compound components work well for anything with a defined structure: tabs, accordions, dialogs, carousels with separate controls, form field groups.
Classname overrides
Avoid exposing every possible style as a prop (headerColor, borderRadius, hoverBackground). That turns the component into a style configuration engine with no end.
The right escape hatch is className overrides at structural points:
type DropdownProps = {
className?: string; // the root element
menuClassName?: string; // the menu container
itemClassName?: string; // each item
};
Consumers can override with Tailwind utilities or CSS modules without fighting specificity or waiting for a new prop. The component stays opinionated about behavior and structure; the consumer controls appearance.
Where this appears
Autocomplete — the canonical render props + controller case. The fetch/debounce/keyboard logic is shared; the dropdown vs. chip input vs. command palette UIs are not.
Image Carousel — controlled (currentIndex + onIndexChange) for synced carousels; uncontrolled (defaultIndex) for standalone ones. Compound components for Carousel.Track, Carousel.Prev, Carousel.Next, Carousel.Dots.
Dropdown Menu — compound components. The structure varies enough across products (with icons, without, with nested menus) that a flat props API breaks down quickly.
Data Table — render props for cell rendering (renderCell), controlled for sort/selection state, classname overrides for row and cell styling.
Poll Widget — controlled for embedded use (parent tracks votes), uncontrolled for standalone embeds.