Lesson 20 of 26
API Design for Shared Components
Designing a shared component's props like a public API — composition over configuration, controlled vs. uncontrolled state, and the specific cost of a boolean-flag explosion.
A component used by one team is a private implementation detail — change it however you like, and only that team feels it. A component used by thirty teams is a public API in every sense that matters, even though it's never published to npm outside your own org: breaking it breaks other people's code, and every design decision in its props is a decision other engineers have to learn and live with. Designing that API well is a distinct skill from building the component's internals.
Composition over configuration
The single most consequential API-design instinct for a shared component is resisting the pull toward one component with an ever-growing list of boolean and enum props, each covering one more use case:
// The configuration trap: every new need becomes a new flag,
// and the combinations nobody tested keep growing.
<Modal
showHeader
showFooter
footerAlign="right"
hideOverlay={isEmbedded}
disableEscapeKey={isCritical}
/>// Composition: the consumer arranges pieces explicitly,
// and a new arrangement doesn't require a new flag on Modal itself.
<Modal>
<Modal.Header />
<Modal.Body>...</Modal.Body>
<Modal.Footer align="right" />
</Modal>The configuration approach feels convenient at first — one component,
covers everything — but every flag multiplies the number of states the
component can be in, and most combinations are never actually intended or
tested. hideOverlay combined with disableEscapeKey might do something
nobody designed for, discovered only when a consumer happens to set both.
The composition approach trades that combinatorial risk for a slightly more
verbose call site, in exchange for behavior that's explicit, and new
arrangements that don't require touching the shared component's own code at
all.
Controlled vs. uncontrolled
Borrowed directly from how the HTML spec itself already distinguishes form
inputs: a controlled component's value is owned by the consumer's own
state, passed in and updated via a value/onChange pair — full control,
at the cost of requiring that wiring even for the simple case. An
uncontrolled component manages its own internal state, reporting out
only via a callback when something changes — simpler to drop in, but harder
for a consumer to drive programmatically (resetting it externally, syncing
it with another input).
A shared library often needs to support both, because both real needs exist
across consumers: a form library wiring up validation genuinely needs
control; a simple "type a comment" box usually doesn't. A common, well-tested
pattern is defaulting to uncontrolled behavior (a component that works with
zero required props beyond the essentials) while accepting optional
value/onChange props that, when provided, hand control to the consumer —
giving both groups a genuinely good experience from the same component.
Sensible defaults and escape hatches
A well-designed shared component's default behavior should cover the common
case with zero configuration, while still providing an explicit escape hatch
for the genuinely uncommon one — a className or style prop accepted and
merged (not overridden) for one-off visual tweaks, a render prop or asChild
pattern for a consumer who needs to change the rendered element entirely.
The goal isn't preventing customization — it's channeling it through a
narrow, predictable surface rather than an ever-multiplying set of
special-case flags baked into the component itself.
Naming and consistency across the library
A prop named isOpen on one component and visible on another, meaning the
same thing, is a small inconsistency that compounds: a consumer working
across several components in the library has to remember which convention
each one happens to use, rather than being able to guess correctly based on
the rest of the library. Consistent naming conventions — for boolean props,
for event handler names, for size/variant enums — are a cheap, easy-to-skip
discipline that measurably reduces how much a consumer has to look up versus
simply predict correctly.
What to remember
- Composable sub-components scale better than an ever-growing list of boolean/enum flags on one component, which multiply into untested combinations.
- Controlled and uncontrolled are genuinely different needs — a shared component often benefits from supporting both, defaulting to the simpler uncontrolled case.
- Good defaults should cover the common case with zero configuration, with narrow, explicit escape hatches (className, asChild) for genuine one-offs.
- Consistent naming across a component library is what lets a consumer predict an unfamiliar component's API instead of having to look it up every time.
Check yourself
3 questions · pass 3/3 to unlock Versioning and Breaking Changes
1.A shared Modal component grows a
showCloseButton,showFooter,footerAlignment,hideOverlay, anddisableEscapeKeyprop over time, each added for a different consumer's specific need. What's the underlying problem with this trajectory?2.What does 'composition over configuration' mean as a design principle for a shared component's API?
3.What's the practical difference between a 'controlled' and an 'uncontrolled' shared input component, and why does a shared library often need to support both?
3 left to answer