Component API design: why a UI component scales or rots
8 booleans = 256 states -> 12 states in 2 props, and the next variant costs 0. A component scales or rots on one variable: its API contract.
What component API design really means: naming the contract#
Most guides teach a pattern and stop there. This one names the thing underneath every pattern. A component's API is a contract, and component API design is the work of shaping that contract well. Because the contract is what every consumer depends on, its shape decides your fate. Get it small and honest, and the component scales. Let it sprawl, and it rots.
The contract is not one dimension. Instead it has five faces, and they move together. The diagram below is the hub the rest of this guide walks around.
Read the five faces as one surface, not a checklist. When you change any face, you change the agreement every call site relies on. Therefore the cheap changes are the additive ones, and the expensive changes are the ones that break existing consumers.
Five faces, one agreement with every consumer#
Here is each face in plain terms, with one concrete example.
- Props are the named inputs. For example,
variant="danger"picks a look. - Slots are the open holes for content the component should not enumerate. For instance, the button's label and icon.
- State ownership is who holds the value: the component, or the parent. An accordion that tracks its own open panel owns that state.
- Styling surface is how much of the look a caller may touch. A bounded set of tokens and one class hook, not a free-for-all.
- Accessibility guarantees are the roles, focus, and keyboard behaviour the primitive promises. A menu that manages arrow-key focus owns that promise.
These are not five separate lessons. Instead they are five faces of a single agreement, and strong component API design keeps all five small and honest at once.
Why "reusable" is a symptom, not the cause#
Search results love the word reusable. Still, chasing reusability head-on is how components rot. A component is reusable because its contract is small, honest, and additive. Reusability is the readout, not the dial you turn.
Consider what happens when you optimise for reuse directly. You add a prop for every new caller, because each new prop looks like more reach. Consequently the contract balloons, the state space explodes, and the "reusable" component becomes the one nobody dares change. In short, aim at the contract and reuse follows. Aim at reuse and you get sprawl.
The rot in numbers: how a Button drowns in booleans#
Abstract advice does not land, so here is the rot as arithmetic. Start with a Button that accretes one boolean per variant. Every new look adds a flag, and the flags multiply into states you never meant to allow.
Eight booleans, 256 nominal states, most contradictory#
Give the Button eight booleans: isPrimary, isSecondary, isGhost, isDanger, isSmall, isLarge, isLoading, and isFullWidth. Because each is independent, the type allows 2^8 = 256 combinations. Most have no defined meaning. For example, isPrimary and isDanger both true is a look you never designed. Recompose the same UI around two enums plus slots, and the reachable set drops to a dozen.
256 states
Configuration API (8 booleans)
12 states
Composition API (2 enums + slots)
The 256 is exact: 2^8 independent booleans. Only 60 of those 256 states are internally coherent, so 196 are contradictions the type system never stopped you from writing. The composition contract expresses the same looks in 12 valid states with contradiction made impossible.
| Option | reachable states the contract allows |
|---|---|
| Configuration API (8 booleans) | 256 states |
| Composition API (2 enums + slots) | 12 states |
Every new variant doubles the test surface#
The number that should scare you is not 256. Instead it is how the number grows. Because each boolean is one more independent bit, the nominal surface is 2^k: 64 states at six booleans, 128 at seven, 256 at eight, and 512 the moment you add a ninth isSuccess variant. Every exhaustive test, every visual snapshot, and every reviewer reasoning about props inherits that doubling. The explorer below renders this 2^k ladder beside the live button, so you can watch the boolean surface double one bit at a time.
Compare that to the composition contract. There, a ninth variant is one enum member. Therefore the valid set moves from 12 to 15, and no new prop combination appears to test. The boolean contract grows its surface geometrically. The composition contract grows it by roughly zero.
The call-sites that break when a required prop changes#
Prop count is only half the cost. The other half is blast radius. When you change the shape of a required prop, every consumer that passes it breaks at once. A widely-used component turns a one-line change into a repository-wide migration.
Feel it: toggle the same Button between its two APIs#
Numbers convince, but touching the thing teaches faster. The explorer below runs the same Button under both contracts. First flip the eight booleans on the configuration API and watch the highlighter light up contradictory combinations. Then switch the segmented control to the composition API and see the state space close to 12 valid states with contradiction made impossible.
ContradictionThe type signature let you write a state with no defined meaning.
- Two variant flags set at once (isPrimary + isDanger) name conflicting looks.
Add one isSuccess variant and the surface doubles from 256 to 512 nominal states. Nothing in this API stops a caller reaching the contradictions.
The recomposition: same UI, zero new props#
Now the fix, as one before-and-after. The UI does not change at all. Only the contract changes. Read the diff: eight booleans leave, two enums and three slots arrive.
// Button props: the recomposition, as a diff.
// The SAME button UI, two contracts.
- isPrimary?: boolean
- isSecondary?: boolean
- isGhost?: boolean
- isDanger?: boolean
- isSmall?: boolean
- isLarge?: boolean
- isLoading?: boolean
- isFullWidth?: boolean
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
+ size?: 'sm' | 'md' | 'lg'
+ loading?: boolean
+ fullWidth?: boolean
+ leftIcon?: ReactNode // slot
+ rightIcon?: ReactNode // slot
+ children: ReactNode // slot Two enums and three slots replace eight booleans#
Walk the after-code one idea at a time. First the two enums, variant and size, close the invalid state space. Because a value can be exactly one variant, "primary and danger at once" is now unrepresentable. Second the slots, children, leftIcon, and rightIcon, absorb open-ended content the parent should never enumerate. Third the two remaining booleans, loading and fullWidth, are genuinely independent toggles, so a boolean is the honest shape for them.
Notice what did not happen. You did not add a prop for the icon markup. Instead a slot took it. Consequently new content never grows the prop list, and new looks never grow the boolean count.
Proof this scales in practice: Radix Accordion#
This is not a toy claim, so here is a primary source. Radix UI's Accordion expresses an unlimited number of items and both controlled and uncontrolled state through five composable parts: Root, Item, Header, Trigger, and Content. It exposes just three state props on the Root: value, defaultValue, and onValueChange.
Because items are composed as children, adding another item needs no new prop. Furthermore a new visual variant is a styling concern, not an API concern. See the Radix Accordion primitive for the full part list. That is composition scaling without prop growth, shipped and battle-tested.
The five faces, one lever at a time#
The button is the whole thesis in miniature. Now here is each face as its own lever, with the three concerns most guides skip folded back in.
Props: prefer enums over boolean accretion#
Mutually exclusive states belong in one enum, not N booleans. An enum closes the invalid space that booleans leave open. Pair it with a sensible default so the common case needs no prop, and state one escape hatch so the rare case is not blocked.
// Mutually exclusive states belong in ONE enum, not N booleans.
type ButtonProps = {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
}
// Sensible default: the common case needs no prop at all.
function Button({ variant = 'primary', size = 'md' }: ButtonProps) {
// ...render
}
// Escape hatch, stated on purpose: asChild swaps the host element
// without the component enumerating every tag it might wrap.
// <Button asChild><a href="/pricing">Pricing</a></Button> Slots and composition: children and compound parts#
Slots absorb the variation a parent cannot predict. A children slot takes arbitrary content, and named slots take specific holes like an icon. Meanwhile the compound-component pattern lets a cluster of related parts assemble without the parent passing a dozen coupled props.
// children is the open-ended slot the parent must NOT enumerate.
<Button leftIcon={<Spinner />}>Saving...</Button>
// Compound parts absorb structure the parent cannot predict.
<Menu>
<Menu.Trigger>Open</Menu.Trigger>
<Menu.List>
<Menu.Item>Edit</Menu.Item>
<Menu.Item>Delete</Menu.Item>
</Menu.List>
</Menu> This is the same instinct behind good rendering boundaries. For a companion read on how rendering strategy shapes component boundaries, the split between static and dynamic is another place where the contract, not the framework, does the deciding.
State ownership: controlled vs uncontrolled, and who holds it#
Who owns a value is a first-class contract decision, not an afterthought. An uncontrolled component holds its own state and exposes defaultValue. A controlled component hands the value to the parent and emits onChange. The escape hatch is supporting both, so a caller can start uncontrolled and graduate to control. Walk the decision below.
Get this face wrong and every consumer feels it. For a deeper look at streaming state patterns that keep component contracts predictable, the same ownership question decides how a component behaves while data is still arriving.
Styling that does not leak#
A component's look is part of its contract too. Expose design tokens and one bounded class hook, not an open className free-for-all. Because an unbounded style surface lets any caller override your token system by accident, it leaks, and the leak spreads across the app. Keep the surface small and the look stays coherent.
// Tokens are part of the contract; the surface is bounded on purpose.
const button = cva('btn', {
variants: {
variant: { primary: 'btn--primary', danger: 'btn--danger' },
size: { sm: 'btn--sm', md: 'btn--md', lg: 'btn--lg' },
},
})
// ONE bounded style hook, not an open className free-for-all that lets a
// caller reach in and quietly override the token system.
// <Button class={button({ variant, size })} /> Accessibility baked into the primitive, not bolted on#
The decision table: new requirement to the right lever#
A new requirement lands almost every week. The question is never "add a prop or not". Instead it is "which face of the contract absorbs this". Cross-reference the row that matches your requirement, then reach for the lever beside it.
| New requirement | Wrong reflex | Right contract lever | Change cost |
|---|---|---|---|
| One more mutually-exclusive look | Wrong reflexAdd a boolean | Right contract leverAdd a member to the variant enum | Change cost0 new props, non-breaking |
| Open-ended content inside | Wrong reflexAdd a prop per content type | Right contract leverExpose a children or named slot | Change costAdditive, non-breaking |
| A cluster of related parts | Wrong reflexAdd many coupled props | Right contract leverCompound components (Root / Item) | Change costAdditive parts, non-breaking |
| Parent must read or override state | Wrong reflexReach in with a ref hack | Right contract leverControlled value + onChange, keep defaultValue | Change costAdditive if defaulted |
| A new visual accent | Wrong reflexHardcode a colour | Right contract leverA design token in the bounded style surface | Change costToken change, no API change |
| Two genuinely different widgets | Wrong reflexOne mega-component with a kind flag | Right contract leverSplit into two components | Change costNew component, old one untouched |
Read down the change-cost column and the pattern is obvious. The right lever is almost always additive. The wrong reflex is almost always the one that reshapes required surface or multiplies booleans.
Same principle, four frameworks#
This is a software engineering principle, not React trivia. The slot-and-variant idea reads the same in React, Vue, Svelte, and a plain Web Component. Switch the tabs and watch one contract survive four syntaxes.
// React: children IS the slot.
function Button({ variant = 'primary', children }) {
return <button className={variant}>{children}</button>
}
// <Button variant="danger">Delete <TrashIcon /></Button> <!-- Vue: the default <slot />. -->
<template>
<button :class="variant"><slot /></button>
</template>
<script setup>
defineProps({ variant: { type: String, default: 'primary' } })
</script>
<!-- <Button variant="danger">Delete <TrashIcon /></Button> --> <!-- Svelte 5: a children snippet. -->
<script>
let { variant = 'primary', children } = $props()
</script>
<button class={variant}>{@render children()}</button>
<!-- <Button variant="danger">Delete <TrashIcon /></Button> --> // Web Component: the native <slot>.
class UiButton extends HTMLElement {
connectedCallback() {
const variant = this.getAttribute('variant') ?? 'primary'
this.attachShadow({ mode: 'open' }).innerHTML =
`<button class="${variant}"><slot></slot></button>`
}
}
customElements.define('ui-button', UiButton)
// <ui-button variant="danger">Delete</ui-button> Each snippet takes a variant input and a content slot. Although the keywords differ, the contract is identical. Because the principle is not tied to a library, you can carry it to whatever framework the next project picks.
Evolving the contract without breaking consumers#
Contracts must grow, and growth is where most rot creeps in. The rule is simple. Additive change is safe, and reshaping required surface is not. A new optional prop with a default breaks nobody. A new slot breaks nobody. One more boolean, by contrast, multiplies the state space and every test that guards it.
// Additive change is non-breaking. Boolean accretion is breaking-by-growth.
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
+ size?: 'sm' | 'md' | 'lg' // new, optional, defaulted -> nobody breaks
+ endSlot?: ReactNode // new slot -> old call sites ignore it
- isSuccess?: boolean // one more boolean: 256 -> 512 nominal states When you must remove or reshape something, deprecate first. Keep the old prop working, mark it deprecated, and give consumers a release to migrate. Then remove it in a clearly-versioned change. This same discipline shows up in frontend architecture choices that also serve SEO, where stable public surfaces beat clever churn.
When NOT to reach for composition#
Where this fits our software engineering practice#
Component API design compounds. A codebase full of small honest contracts stays cheap to change for years, whereas one full of boolean-soaked components ossifies. This is the same longevity thinking behind PWA component patterns built for longevity, where components have to survive offline states and updates without churn.
Because durable contracts are also what let a team scale a product without a rewrite, this is core to how we work. If you want a second read on a component library that is starting to sprawl, that is exactly the kind of problem our team enjoys.
Watching a component library drown in boolean props, and want a second read on the contract before the next rewrite? No pressure and no lock-in.
Atyantik's scale and optimize software engineering practice