A <Button> component with eighteen props is a code smell. The team has been adding one prop every time a designer wants a new variant, and the file is now eight hundred lines of branching on variant, size, loading, iconPosition, and a dozen siblings. Configuration scales linearly with the number of designs; composition does not. This is a post about moving a component boundary from one to the other.
The accumulation problem
The configuration-heavy <Button> starts modest. The first version takes variant and onClick. Two months in, someone needs an icon; icon and iconPosition arrive. A month after that, loading states for async forms add loading and loadingText. Then full-width on mobile, then a left addon for currency inputs, then a tooltip when disabled. Each prop is small. The aggregate is not.
<Button
size="md"
variant="primary"
icon={ArrowRightIcon}
iconPosition="right"
fullWidth
loading={isSubmitting}
loadingText="Saving…"
disabled={!isValid}
asChild={false}
leftAddon={<Currency code="USD" />}
rightAddon={null}
tooltip="Fill all fields"
onClick={handleSubmit}
>
Save
</Button>The internal implementation is now a tree of conditionals — render the icon if icon is set, swap to a spinner if loading, replace the children with loadingText, wrap in a tooltip if disabled and tooltip are both truthy. Every new design is another conditional. The component is doing the job that JSX was designed to do.
The composition alternative
The same surface, written with composition, uses three small primitives — a Button that handles the press behaviour and the variant classes, a ButtonIcon that handles icon sizing, and a Spinner that handles the loading state. The caller assembles them in JSX:
<Button variant="primary" onClick={handleSubmit} disabled={!isValid}>
{isSubmitting ? (
<>
<Spinner />
Saving…
</>
) : (
<>
Save
<ButtonIcon icon={ArrowRightIcon} />
</>
)}
</Button>The Button implementation no longer branches on icon or loading state — it renders children and applies the variant classes. New designs do not require new props; they require new JSX at the call site. The component boundary stays small while the things it can render stay open-ended.
Headless libraries as a reference
The pattern is not new. Radix UI, Headless UI, and React Aria have been shipping it for years under the name "headless components" — primitives that expose behaviour and accessibility, not styling. A Radix dialog looks like this:
<Dialog.Root>
<Dialog.Trigger asChild>
<Button>Open</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40" />
<Dialog.Content className="...">
<Dialog.Title>Confirm action</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<Dialog.Close asChild>
<Button variant="secondary">Cancel</Button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>Every piece is a slot. Radix handles the focus trap, the escape-key binding, the ARIA roles, the portal mounting. The caller handles the styling and the content. There is no variant prop on Dialog.Content because there does not need to be — if a variant means "different styles," styles go on the element; if it means "different behaviour," that behaviour is a different component.
The four properties of a healthy component boundary
- Small surface (five props or fewer). Most primitives can carry their job with a handful of props plus
children. If the prop list crosses ten, the component is doing two jobs that want to be separated. - Composition slots. Children, render props, and the
asChildpattern (which forwards the props to the first child instead of wrapping it) all let callers slot in arbitrary content. The primitive does not decide what goes inside; it decides how the container behaves. - Variants as styling, not as logic.
variant="primary"should resolve to a class string or a styled-component theme key — not to a different render path. If a variant changes which children render, it is a different component. - Accessibility at the primitive level. Focus, keyboard handling, ARIA attributes, and screen-reader semantics belong to the primitive — not to a wrapper a few layers up. Wrappers forget; primitives are reused often enough that accessibility bugs surface and get fixed.
The refactoring path
Configuration-heavy components are usually called from hundreds of places. Rewriting the call sites in one pass is not realistic. The working pattern is three steps spread across weeks:
- Extract the primitive. Build the new composable
Buttonalongside the old one — different file, no shared code. Get the new one reviewed and used in one new feature first. - Thin wrapper for the old API. Implement the old
<LegacyButton>as a wrapper around the new<Button>that maps the old props to the new composition. MarkLegacyButtonas deprecated with a JSDoc comment. The old call sites keep working unchanged. - Migrate call sites over weeks. Every PR that touches a file with a
LegacyButtonmigrates it. Code-mods catch the easy ones. After a quarter the legacy wrapper has no references and can be deleted.
The cost of this path is real but bounded. The cost of leaving the configuration-heavy component in place is unbounded — every designer request adds another prop, and every prop adds another conditional branch that the next developer has to read past.
Where the pattern does not fit
Composition is not the answer everywhere. Form fields with deep validation logic — a date picker that has to coordinate a calendar, a text input, locale parsing, and error messages — are usually better as configured components, because the configuration is encoding behaviour, not appearance. Charts with complex data binding are similar: the data shape and the rendered output are tightly coupled, and exposing the internals as slots leaks too much implementation.
The rule of thumb is to ask what the props are encoding. If they are encoding what the component looks like, composition almost always wins. If they are encoding what the component does, configuration is honest about it.
The habit that compounds
Every time you reach for a new prop on an existing component, stop and ask whether the change is a styling decision (variant token), a content decision (slot), or a behavioural decision (different component). Two of those three answers should never be a prop. The codebases that age well are the ones where this question is asked out loud in code review; the ones where it is not are the codebases that people quit jobs over.
Related reading
Composition is one piece of a healthy frontend architecture. The state-ownership post covers the layer that sits underneath components, and the data-fetching patterns post covers the layer that feeds them. For the broader theme of code that reads well a year later, the clean-code guide is the place to start. All three sit inside the frontend-architecture topic.
About the writers
Developer educator at ShareCode. Writes the tutorial track — Python, JavaScript debugging, coding-interview prep, and the everyday code-quality habits that hold up in real codebases.
More from Kajal
Founder of ShareCode. Writes the engineering deep-dives on this site — WebRTC, Firebase Auth, real-time sync, and the production patterns behind the editor itself.
More from Kishan
Refactoring a prop-heavy component?
Paste the current implementation and the call sites into a code space, share the link with a teammate, and walk through the three-step migration together. The legacy wrapper trick keeps the old call sites working while the new primitive lands.
Open a code space →