This article is published in English.
When Reusable React Components Backfire: Prop Explosion and the Fix
See how premature reuse turns a simple React component into a prop-heavy liability, and how duplication, compound components and the Rule of Three prevent it.
Shared components are supposed to save time, yet many frontend codebases have one component that everybody is afraid to edit: a <Modal /> or <Card /> with dozens of props, where fixing a bug on one screen breaks three others. That outcome is rarely the result of carelessness; it is the natural endpoint of reusing UI too early. This article traces how a component turns into that liability, explains why UI duplication is often the cheaper choice, and shows two practical tools for avoiding it: compound components and the Rule of Three.
How a harmless shortcut becomes a thirty-two-prop component
The pattern usually starts with a reasonable request. A designer hands over a checkout screen with a dialog that almost matches the confirmation dialog on the settings page. The differences are small: the actions sit on the left, the title has an orange badge beside it, and a thin divider separates the buttons from the content.
In review, someone points out that a <Modal /> already exists and suggests reusing it. A few hours later a pull request arrives that adds four props: hasOrangeBadge, alignActionsLeft, showDividerLine and badgeText.
Repeat that a dozen times over a year. The same modal now takes thirty-two props, contains fifteen nested ternaries and twelve boolean flags, and relies on three useEffect hooks to keep internal animations in sync with various prop combinations. Then someone adjusts one line of padding to fix a billing bug and unintentionally breaks the modal on four unrelated screens. An effort to keep code clean and reusable has produced the component nobody wants to own.
Why DRY applies differently to UI
Don't Repeat Yourself is sound advice for a reason. When business logic such as a payment calculation or a permission check is duplicated, a fix applied to one copy leaves the others broken, and the copies drift apart.
UI components change for different reasons. Business rules change when the domain changes. Interfaces change with user journeys, responsive breakpoints, accessibility requirements, product decisions and design experiments, and those forces act on each screen independently.
Two elements that look alike are not necessarily the same concept. Merging two UI patterns into one shared component before their designs have settled creates a coupling between features that did not exist before. From then on, a design tweak for onboarding can require re-testing settings, billing and analytics, simply because they happen to render the same component. At that point reuse is working against you.
Anatomy of prop explosion
It helps to watch the drift happen one sprint at a time. The starting point is a card with a clear, minimal contract: a title, a description and an optional click handler.
interface CardProps {
title: string;
description: string;
onClick?: () => void;
}
The implementation is equally small and easy to read:
export function Card({ title, description, onClick }: CardProps) {
return (
<div className="card" onClick={onClick}>
<h3>{title}</h3>
<p>{description}</p>
</div>
);
}
Nothing is wrong with this component. Then, in sprint 4, marketing wants blog cards with an image on top, so two optional image props appear:
interface CardProps {
// ...
imageUrl?: string;
imageAlt?: string;
}
In sprint 7 the dashboard team asks for a corner action button, visible only on active items. That takes a flag, an icon and a handler:
interface CardProps {
// ...
hasTopRightAction?: boolean;
topRightActionIcon?: React.ReactNode;
onTopRightAction?: () => void;
}
By sprint 12 the analytics team wants a second line under the title, a status badge in three colors and a footer that can expand, which adds six more props:
interface CardProps {
// ...
subtitle?: string;
badgeText?: string;
badgeVariant?: "success" | "warning" | "danger";
isExpandable?: boolean;
expandedContent?: React.ReactNode;
defaultExpanded?: boolean;
}
By sprint 20 the render function has become a series of conditions. The root element computes its class from a flag, and the image renders only when a URL is present:
export function Card(props: CardProps) {
return (
<div
className={`card ${
props.isExpandable ? "card-expandable" : ""
}`}
>
{props.imageUrl && (
<img src={props.imageUrl} alt={props.imageAlt} />
)}
The header wrapper opens with the title:
<div className="card-header">
<div>
<h3>{props.title}</h3>
followed by a subtitle that appears only if provided:
{props.subtitle && <h4>{props.subtitle}</h4>}
</div>
The top-right action depends on a boolean flag rather than on whether a handler exists, so it is possible to set the flag and forget the icon, or pass an icon and forget the flag:
{props.hasTopRightAction && (
<button onClick={props.onTopRightAction}>
{props.topRightActionIcon}
</button>
)}
</div>
The badge builds a class name from its variant and silently falls back to a default variant that the type does not even list:
{props.badgeText && (
<span
className={`badge badge-${
props.badgeVariant || "default"
}`}
>
{props.badgeText}
</span>
)}
The description, the only piece left from the original design, sits in the middle:
<p>{props.description}</p>
And the expandable footer closes the component. Notice that isExpandable controls both the root class and the footer, while defaultExpanded from the interface is not used anywhere in the markup:
{props.isExpandable && (
<div className="card-footer">
{props.expandedContent}
</div>
)}
</div>
);
}
Those small inconsistencies are typical. Once a component has this many flags, nobody can see every combination at once, and invalid or half-implemented states creep in. Every consumer of <Card /> must learn a growing list of options and work out which combinations are supported. The abstraction is now harder to understand than the plain markup it was meant to replace.
Duplication is often cheaper than the wrong abstraction
A well-known line in software design, popularized by Sandi Metz, puts it bluntly: "Duplication is far cheaper than the wrong abstraction." Frontend work is where that advice pays off most.
When two components merely resemble each other, keeping them separate is frequently the safer choice. Suppose BillingModal and OnboardingModal are independent components:
- A change to
BillingModalcannot affectOnboardingModal. - Deleting the onboarding feature deletes its modal and all of its feature-specific logic with it.
- Each modal evolves according to its own requirements.
- A small visual change does not require understanding dozens of props that belong to other features.
The duplicated JSX costs a few extra lines. The wrong abstraction costs far more, in debugging time, regression testing and ongoing maintenance. Not every repeated fragment of markup has earned a shared component.
Compound components: composition over configuration
Genuine reuse still has its place, especially in a design system. The key is how the shared component exposes flexibility. Instead of a single component steered by a growing list of booleans, a compound component offers a set of small, related parts that consumers assemble themselves.
Here is a billing dialog built that way. The owning component keeps the open state locally:
export function BillingSettings() {
const [isOpen, setIsOpen] = useState(false);
The root <Modal> receives only what it genuinely owns, open state and a change callback, and the overlay is its own piece:
return (
<Modal open={isOpen} onOpenChange={setIsOpen}>
<Modal.Overlay />
The content area contains a header, and the header contains a title:
<Modal.Content>
<Modal.Header>
<Modal.Title>Update Billing Plan</Modal.Title>
A badge is simply another child of the header, with its variant expressed as a prop on the badge alone:
<Modal.Badge variant="warning">
Action Required
</Modal.Badge>
</Modal.Header>
The body holds arbitrary content, starting with a message:
<Modal.Body>
<p>
Please update your payment method to avoid account suspension.
</p>
and continuing with a feature-specific form the modal itself knows nothing about:
<CreditCardForm />
</Modal.Body>
The footer controls its own alignment and contains ordinary buttons, the first of which closes the dialog:
<Modal.Footer align="right">
<Button
variant="ghost"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
The primary action completes the footer and the tree:
<Button variant="primary">
Save Changes
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}
The structural difference matters. When a modal needs a badge, there is no showBadge prop to add; you render <Modal.Badge />. When a screen needs custom content, you place it where it belongs instead of inventing another flag. The benefits follow directly:
- No prop bloat. A modal without a badge or footer simply does not render
<Modal.Badge />or<Modal.Footer />. - More flexibility. An icon next to the title goes inside
<Modal.Header>; no new prop is needed. - Isolated styling. Changing
<Modal.Badge />does not have to touch the core container. - Readable structure. The JSX shows the layout directly, instead of forcing readers to open a TypeScript interface and work out which prop combination yields which layout.
Under the hood, compound components usually share state such as open through React context, which is how <Modal.Footer> or a close button can reach it without prop drilling. Composition hands control to the consumer without turning the component into a configuration object. The approach is not free: the design system has to document which parts may be nested where, and consumers write a little more markup per use. For another take on this refactor, see our guide on fixing prop overload with composition and slots.
The Rule of Three for extracting shared components
A simple heuristic helps decide when an abstraction is justified: wait for the third real occurrence.
First occurrence: write it in place
Put the markup directly inside the view that needs it. Resist the urge to abstract, and keep styles and structure next to the feature.
Second occurrence: copy and adapt
When another screen needs something similar, creating a global component becomes very tempting. Instead, copy the markup and adjust it to its new context. You now have two concrete examples, and over time you can observe where they genuinely diverge rather than speculating about future requirements.
Third occurrence: extract with evidence
When a third, distinct screen needs the same visual and behavioral pattern, you finally have enough evidence to see what is truly shared and what differs. Waiting this long reveals the real invariants, the parts that stay the same every time, and the real variations that must stay flexible. Those variations are good candidates for composition slots rather than boolean props.
The goal is not to avoid reusable components. It is to avoid building abstractions on assumptions.
Key takeaways
- Watch for prop proliferation. When a component keeps gaining configuration props for unrelated use cases, question the abstraction before adding another.
- Prefer composition over configuration. Children, slots and compound components provide flexibility without a new boolean every time the design changes.
- Accept small amounts of duplication. A pair of small, separate components that share some markup is often easier to maintain than one component that tries to cover every variant.
- Apply the Rule of Three. Let shared components emerge from several real use cases rather than predictions.
Good frontend architecture is not measured in the fewest lines of code, but in how safely the code can change without a ripple effect across the app. Sometimes the best component is not the one reused everywhere, but the one left alone.