AniUI Academy

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.

11 min read

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

up to 50
  1. 1.A shared Modal component grows a showCloseButton, showFooter, footerAlignment, hideOverlay, and disableEscapeKey prop over time, each added for a different consumer's specific need. What's the underlying problem with this trajectory?

  2. 2.What does 'composition over configuration' mean as a design principle for a shared component's API?

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