Hand-drawn bar chart: 8 booleans, or 2 enums plus slots. Configuration API: 8 booleans (256 states); One more variant boolean (512 states); Composition API: 2 enums + slots (12 states).

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.

The five faces of a component's API contractOne contract, five faces: props, slots, state ownership, styling surface, and accessibility guarantees. Each face carries its own change-cost and its own blast radius across the consumers that depend on it.

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.

The same button, two API contracts~21x smaller

256 states

Configuration API (8 booleans)

Closed set

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.

The same button, two API contracts (reachable states the contract allows)
Optionreachable 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.

The same button, two API contracts
Variant booleans (mutually exclusive)
Size booleans (mutually exclusive)
State booleans (independent)
Live button
Saving...

ContradictionThe type signature let you write a state with no defined meaning.

  • Two variant flags set at once (isPrimary + isDanger) name conflicting looks.
256nominal states (2^8 booleans)
60are internally coherent; the other 196 are contradictions
Each boolean doubles the surface (2^k)
6 booleans64
7 booleans128
8 booleans256
9 booleans (+1 variant)512

Add one isSuccess variant and the surface doubles from 256 to 512 nominal states. Nothing in this API stops a caller reaching the contradictions.

Use the segmented toggle to switch API style. In the configuration API, flip the booleans and watch contradictory combinations light up in the danger tint with a live verdict. In the composition API, pick a variant and size and see every state stay coherent. Keyboard: Tab to a control, arrow keys move within a group, Space toggles. With JavaScript off, the default configuration renders with its contradiction already shown.

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.ts · diff
// 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.

Button.tsx · tsx
// 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.

composition.tsx · tsx
// 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.

Controlled, uncontrolled, or hybrid: who owns the stateStart from one piece of state. If no parent needs to read or override it, keep it uncontrolled with defaultValue. If a parent must be the single source of truth, make it controlled with value plus onChange. The hybrid supports both and is the escape hatch.

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.

button.styles.ts · tsx
// 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.

A new requirement, the wrong reflex, and the contract lever that actually fits
New requirementWrong reflexRight contract leverChange cost
One more mutually-exclusive lookAdd a booleanAdd a member to the variant enum0 new props, non-breaking
Open-ended content insideAdd a prop per content typeExpose a children or named slotAdditive, non-breaking
A cluster of related partsAdd many coupled propsCompound components (Root / Item)Additive parts, non-breaking
Parent must read or override stateReach in with a ref hackControlled value + onChange, keep defaultValueAdditive if defaulted
A new visual accentHardcode a colourA design token in the bounded style surfaceToken change, no API change
Two genuinely different widgetsOne mega-component with a kind flagSplit into two componentsNew 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.

tsx
// React: children IS the slot.
function Button({ variant = 'primary', children }) {
  return <button className={variant}>{children}</button>
}
// <Button variant="danger">Delete <TrashIcon /></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.

Button.props.ts · diff
// 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

Vishalkumar Prajapati

Software Engineer, Atyantik Technologies

Vishalkumar Prajapati is a Software Engineer at Atyantik Technologies, a software product studio building web platforms, mobile apps, and integrated systems since 2015. Vishalkumar writes about the software engineering practice behind shipping and maintaining real software.

More from Vishalkumar PrajapatiAbout AtyantikScale and optimize

Keep reading