The Button That Wanted to Be Everything: Component Architecture

The Button That Wanted to Be Everything: Component Architecture
Author
Richard Le Peurian Cross
I'm a technical lead with a background in design, front-end development and marketing, focused on building thoughtful digital experiences that balance creativity, usability and solid engineering.
Published

Shared components rarely die of bugs; they die of props. How a humble Button turns into a switchboard, and how composition keeps React APIs small.

A component with no bugs and fourteen props

Picture a design system's Button with fourteen props. A pull request proposes a fifteenth, subtle, for a team that finds ghost too ghostly. It has sat open for nine days. Three reviewers have been tagged, and all three have since left thoughtful comments on other pull requests.

Read the Button's history and something odd appears: it has never had a serious bug. Every commit is a request. The changelog reads like a suggestion box. Shared components rarely fail through defects. They fail through accretion, and the failure shows up as nobody being willing to touch them.

The React docs describe props as 'knobs' you can adjust, serving the same role as arguments to a function. In fact, props are the only argument to your component. Take that literally and our Button is a function with fourteen parameters. Nobody would approve that signature in a code review. JSX simply lets it hide behind tidy named attributes.

So the interesting question for component architecture is why every single prop looked reasonable when it was merged, and why the sum of them is not.

A short history of one Button, prop by prop

Here is a hypothetical but uncomfortably plausible timeline.

The Button ships with primary, because a screen needs a main action. The forms team needs disabled. Checkout asks for loading so customers stop paying twice. The toolbar wants icon, and pagination wants iconRight for its 'Next' arrow. Mobile wants fullWidth. Account settings needs a red danger button for deleting things. Marketing wants the hero call to action to navigate, so isLink arrives. The data tables need compact. The toolbar returns for ghost. Then come iconOnly and three more like it.

Each came with a real ticket, a sensible justification and a small diff. It is how a kitchen drawer fills up: nobody decides to own a garlic press, a cherry pitter and a spiraliser, yet each purchase made sense on the day, and one morning the drawer will not close.

There is a social mechanism too. Once a shared Button exists, adding a prop is the cheapest way for any team to ship its edge case. A new component means a design review; a new boolean means a ten-line pull request. The component ends up shaped like the queue of people who asked it for things, which is Conway's law at the scale of a single file.

Then comes the escape hatch: {...rest}, spread onto the underlying element so consumers can pass whatever a native button accepts. It feels generous. Mostly it hides sprawl, because the real API is now the documented props plus everything forwarded silently. The React docs advise using spread syntax with restraint: 'If you're using it in every other component, something is wrong.'

The states nobody designed

Booleans have a quiet mathematical property: n independent flags produce 2^n combinations. Ten flags give 1,024 theoretical buttons. A design file might show half a dozen. The rest exist only in code, which means they exist for whichever user stumbles on them first.

Some collisions take seconds to find. loading plus disabled: which wins visually, can the button still hold focus, and does a screen reader announce it as busy, unavailable or neither? danger plus ghost: a transparent button with red text, or a red button that mislaid its background? iconOnly with a long label: does the text vanish, truncate or wrap around the icon? iconOnly with no label at all: a control with no accessible name, announced as little more than 'button'.

Every flag adds a branch to the render logic, and every branch adds a row to the matrix that design review and QA are meant to cover. That cost arrives long after the merge, paid by people who were not in the original conversation. A prop costs a few lines to add and a recurring tax to own.

Small semantic traps compound it. React default values apply only when a prop is missing or explicitly undefined; pass null or 0 and the default is skipped. With two props that is trivia. With fourteen, several fed from API responses, it is a bug report waiting for a Tuesday.

Variants are a menu, not a pantry

A pantry holds every ingredient and leaves you to work out dinner. A menu tells you what the kitchen actually cooks. Open-ended flags are a pantry; variants are a menu.

Replace primary, danger and ghost with variant: 'primary' | 'secondary' | 'danger' | 'ghost', and compact with size: 'sm' | 'md' | 'lg'. The union does something a README cannot: mutually exclusive booleans collapse into one choice. danger plus ghost stops being forbidden and becomes impossible to write.

Push further with an idea associated with Richard Feldman's Elm talk 'Making Impossible States Impossible': model the data so invalid combinations cannot be expressed. In TypeScript, that means discriminated unions.

// Before: every combination compiles, including the nonsense ones

type OldButtonProps = {

primary?: boolean;

danger?: boolean;

ghost?: boolean;

disabled?: boolean;

loading?: boolean;

iconOnly?: boolean;

};

// After: one choice per axis, invalid pairs unrepresentable

type Variant = 'primary' | 'secondary' | 'danger' | 'ghost';

type Size = 'sm' | 'md' | 'lg';

type Status = 'idle' | 'loading' | 'disabled';

type Content =

| { iconOnly: true; icon: ReactNode; 'aria-label': string; children?: never }

| { iconOnly?: false; children: ReactNode };

type ButtonProps = { variant?: Variant; size?: Size; status?: Status } & Content;

loading and disabled can no longer both be true, because they are values of a single status. An icon-only button without an aria-label fails to compile. Helpers such as class-variance-authority map variant combinations to class names, so the styling layer can follow the same closed set.

The type definition becomes a design spec the compiler enforces. Design files drift and wiki pages rot, but a union type either matches the component or breaks the build. That makes it the most honest documentation in most design systems.

Configuration versus composition

Configuration asks a component to anticipate every need through props. Composition lets the caller hand over the pieces. The mechanism is unglamorous: props can carry any JavaScript value, including JSX, and children is simply a prop with nicer syntax.

// Configuration: the Button must know about icons and their positions

<Button icon={<SaveIcon />} iconRight label="Save" />

// Composition: the caller arranges the content

<Button>

<SaveIcon aria-hidden />

Save

</Button>

// Named slots: composition with a little structure

<Button startSlot={<SaveIcon aria-hidden />} endSlot={<Kbd>Ctrl+S</Kbd>}>

Save

</Button>

In the composed versions, the Button no longer needs to know what an icon is or which side it sits on. That decision moves to the call site, where the context lives. When a team invents a badge or a shortcut hint, the Button needs no new release.

Composition over configuration is a trade, and the bill arrives in three places. Discoverability drops: autocomplete happily lists iconRight, but nothing suggests putting an icon in endSlot. Consistency drifts as five teams compose five slightly different icon gaps. Accessibility responsibility moves to consumers, who must now remember aria-hidden on decorative icons.

A workable rule for React component API design: configure what must stay consistent, such as variant and size, and compose what varies by context, such as content and adornments. The Button owns its padding, colour and focus ring. The caller owns what goes inside.

Compound components: when the parts need to talk

HTML has done this for decades. A <select> and its <option> elements cooperate without any wiring from you; a <ul> gives its <li> children list semantics. The parent holds shared state and the children read it implicitly.

Compound components bring that pattern to React, usually through Context:

import { createContext, useContext, useState, type ReactNode } from 'react';

type TabsState = { active: string; setActive: (id: string) => void };

const TabsContext = createContext<TabsState | null>(null);

function useTabs() {

const ctx = useContext(TabsContext);

if (!ctx) throw new Error('Tabs parts must be rendered inside <Tabs>');

return ctx;

}

function Tabs({ defaultValue, children }: { defaultValue: string; children: ReactNode }) {

const [active, setActive] = useState(defaultValue);

return <TabsContext.Provider value={{ active, setActive }}>{children}</TabsContext.Provider>;

}

Tabs.List = ({ children }: { children: ReactNode }) => <div role="tablist">{children}</div>;

Tabs.Trigger = ({ value, children }: { value: string; children: ReactNode }) => {

const { active, setActive } = useTabs();

return (

<button role="tab" aria-selected={active === value} onClick={() => setActive(value)}>

{children}

</button>

);

};

Tabs.Panel = ({ value, children }: { value: string; children: ReactNode }) => {

const { active } = useTabs();

return active === value ? <div role="tabpanel">{children}</div> : null;

};

// Usage

<Tabs defaultValue="billing">

<Tabs.List>

<Tabs.Trigger value="profile">Profile</Tabs.Trigger>

<Tabs.Trigger value="billing">Billing</Tabs.Trigger>

</Tabs.List>

<Tabs.Panel value="profile"><ProfileForm /></Tabs.Panel>

<Tabs.Panel value="billing"><BillingForm /></Tabs.Panel>

</Tabs>

The alternative is configuration in disguise: <Tabs tabs={tabConfig} />, where each config object grows a new key whenever someone needs a disabled tab, a badge or a lazily loaded panel. With compound components the public API stays at four parts, however many tabs or layouts appear. A team wanting a badge puts one inside Tabs.Trigger. A production version also needs arrow-key navigation and aria-controls wiring; this sketch shows only the shape.

For a Button, which has one piece, this is overkill. It earns its place in Menu, Tabs, Accordion and Card, where several parts share behaviour.

Headless libraries such as Radix UI popularised a related idea: a Slot component and asChild prop that let a component lend its behaviour and styling to the child element you supply. Getting focus management and ARIA right across all these primitives is real work, and whether to write them or adopt them is the build-versus-buy question in miniature.

When one component is secretly two

isLink is the giveaway. A button performs an action; a link navigates. They differ in semantics, in keyboard behaviour (buttons activate with Space and Enter, links with Enter) and in what assistive technology announces. A screen reader user browsing a page's list of links will never find a button in disguise, and middle-click and 'open in new tab' belong to links alone.

Thinking in React says a component should ideally be concerned with one thing, and decomposed if it grows. Its own example is instructive: a table header can live inside ProductTable, but if it grows complex, for instance by adding sorting, it moves into its own component. The trigger is new responsibility, not line count.

A practical split test:

  • Different semantics: the element means something different to the browser and to assistive technology.
  • Divergent behaviour: events, focus or keyboard interaction work differently.
  • A cluster of props that travel together: href, target and rel only make sense with each other, never alongside type.

Any one is a strong signal. Two means the decision has already been made and the code is waiting to catch up.

Splitting need not duplicate styles. A LinkButton can share the Button's variant function while rendering an anchor. Or, with asChild, the Button lends its styling to an anchor or your router's link component, which keeps its link semantics:

<Button asChild variant="primary">

<a href="/pricing">See pricing</a>

</Button>

Migrating without a mutiny

Knowing the better API is the easy part. A fourteen-prop Button may have hundreds of call sites, spread across repositories owned by people with deadlines.

Start with evidence. Scan consuming codebases for which props are used, and in which combinations. Usage data turns arguments about taste into arguments about evidence: compact is hard to defend passionately if it turns out only three call sites use it.

Then move in stages:

  1. Map, do not break. Keep old flags working by translating them internally, so danger becomes variant='danger'.
  2. Warn in development. Log a deprecation message naming the replacement, and add a lint rule so new usages fail in CI.
  3. Automate the mechanical. A codemod, written with a tool such as jscodeshift, can rewrite <Button danger> across every repository. Reserve human review for ambiguous combinations such as loading plus disabled, where someone must decide what was meant.
  4. Document the composed equivalent. Show every retired flag beside its replacement, so iconRight has an obvious before and after.

Ship the new API alongside the old and remove flags only when usage reaches zero. A migration is a product launch with internal customers, and deserves the same patience.

The cheapest prop is the one you never add

A shared component's API is a promise to every future team. Adding a prop takes an afternoon; removing one takes the migration above. That asymmetry works like a ratchet, easy to turn one way and expensive to turn back, and it should shape every review.

Before accepting a new boolean, ask what it really is. A variant belongs in a closed union. A slot lets the caller supply the content. A separate component is due when semantics or behaviour have diverged. And a one-off belongs in the calling team's own code.

Good component architecture rarely looks impressive from outside. The healthiest Button in a design system is slightly boring: a handful of props, a short type definition, almost nothing to argue about in review. That boredom is the visible residue of a great many deliberate refusals.