Startseite / Artikel / Fixing React Prop Overload with Composition and Slots

Dieser Artikel ist auf Englisch veröffentlicht.

Fixing React Prop Overload with Composition and Slots

Learn why configuration-heavy React props cause maintenance debt, and how inversion of control, composition, and slots produce truly reusable components.

2466 Wörter

A look at how configuration-driven props quietly accumulate maintenance debt, and how inversion of control together with composition leads to genuinely modular UI components.

Picture a pull request from a teammate introducing what they proudly label the "Universal Data Table."

The goal is to settle every table-rendering need across the codebase in one shot.

It ships with thirty-four separate props:

<UniversalDataTable
  data={users}
  columns={columns}
  enablePagination={true}
  paginationType="cursor"
  enableSorting={true}
  showFilterRow={true}
  hasExportToCsvButton={true}
  exportButtonText="Download CSV"
  headerBackground="light-gray"
  denseRows={false}
  enableRowSelection={true}
  selectionMode="multiple"
  renderCustomRowActions={(row) => <DropdownMenu row={row} />}
  onRowClick={(row) => navigate(`/users/${row.id}`)}
/>

Under the hood sits roughly 1,200 lines of logic.

For rendering a list of users, it works great.

A couple of weeks later, marketing asks for a pricing comparison table to go on the public site.

The requirement: three columns, checkmark icons next to each feature row, and a highlighted "Popular" outline around the middle column.

Someone reaches for <UniversalDataTable />, expecting it to just work.

That's when things fall apart.

There's no way to customize individual cell borders.

Header alignment is hardcoded into a specific flex layout.

Turning off the CSV export button means passing hasExportToCsvButton={false} plus a handful of other overrides just to get a clean result.

So the team bolts on four more props:

isPricingTable
popularColumnIndex
customHeaderRenderer
disableGlobalStyles

The file balloons to 1,450 lines.

At this point it isn't reusable at all.

What started as an abstraction has turned into a tangle of special cases pretending to be one component.

1. The Configuration Prop Trap

When a team wants a component to be "reusable," the instinctive move is usually configuration via props.

Every new use case triggers another flag, string enum, callback, or style override tacked onto the props interface.

A typical example:

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  isLoading?: boolean;
  hasLeftIcon?: boolean;
  leftIcon?: React.ReactNode;
  hasRightIcon?: boolean;
  rightIcon?: React.ReactNode;
  isFullWidth?: boolean;
  tooltipText?: string;
  tooltipPosition?: 'top' | 'bottom';
  rounded?: boolean;
}

On the surface this seems adaptable.

The catch is that configuration-based flexibility carries a cost you don't see right away.

1. Combinatorial Explosion

Just ten boolean props already yield 1,024 possible combinations in theory.

You don't have to test all of them to sense how quickly this spirals out of control.

Certain combinations contradict each other.

Others will simply sit unused forever.

Some will render inconsistently depending on viewport width, surrounding content, or how deeply the component is nested.

Over time, what you have is a pile of edge cases rather than a coherent, predictable API.

2. Prop Drilling

Say a tooltip setting has to reach a nested wrapper buried several layers inside the component tree.

Every intermediate component along the way now has to forward a prop it has no actual use for.

Configuration gets threaded through layers purely because the structure demands it, not because it's meaningful there.

That's the mechanism behind prop drilling.

3. Rigid Layout Assumptions

This is where configuration-driven components typically break down for good.

Suppose a new design calls for an icon placed between two lines of button text.

Adding something like:

iconPosition="between"

might patch things for now.

But the moment the design shifts again, what then?

Before long you're maintaining a whole set of variants:

iconPosition="left"
iconPosition="right"
iconPosition="top"
iconPosition="between"
iconPosition="absolute"

The list keeps expanding because the component's internal JSX still dictates the layout outright.

The real issue isn't how many props exist.

It's that the component has taken on too much responsibility for things outside its concern.

2. Inversion of Control: A Better Mental Model

Genuine reusability isn't achieved by trying to predict every future requirement in advance.

It comes from applying Inversion of Control (IoC).

The concept is straightforward:

Rather than having the component dictate everything rendered within it, let whoever is using the component control the parts that need to change.

This is exactly why composition is such a strong pattern in React.

Instead of building a button that has built-in awareness of tooltips, icons, spinners, badges, and every layout permutation, build a button whose main job is simply being a button.

For instance:

export function Button({
  children,
  variant = 'primary',
  className = '',
  ...props
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} ${className}`}
      {...props}
    >
      {children}
    </button>
  );
}

Control over the content now sits with the consumer:

<Tooltip content="Save your profile">
  <Button>
    <SaveIcon />
    <span>Save Changes</span>
  </Button>
</Tooltip>

Notice what's no longer necessary.

The button has zero knowledge that tooltips exist.

There's no hasLeftIcon prop.

There's no leftIcon prop.

There's no iconSpacing prop.

There's no hasSpinner prop.

Need a loading spinner? Just place one inside the button directly.

<Button>
  <Spinner />
  Saving...
</Button>

The component shrinks in size while gaining flexibility.

That trade-off is the key insight here.

A reusable component doesn't need to internally handle every possible scenario. It needs a well-defined boundary that other components can compose against.

3. Slots Make Complex Components Easier to Extend

Composing with children works well for simple elements. But what happens with more elaborate pieces like cards, dialogs, dashboards, or profile panels?

This is where slots and compound components earn their keep.

Picture a conventional profile card built around a pile of props:

<UserProfileCard
  avatarUrl="/avatar.jpg"
  name="Sarah Connor"
  role="Tech Lead"
  status="Active"
  bio="Building reliable frontend systems."
  primaryActionLabel="Send Message"
  onPrimaryAction={sendMessage}
  secondaryActionLabel="View Profile"
  onSecondaryAction={viewProfile}
  badgeColor="green"
/>

At first glance, this feels convenient.

Then the requirements start shifting.

The marketing team wants the person's social handle shown right next to their name. The admin team for enterprise accounts needs a "Suspended" banner stretched across the card. A different product team wants three action buttons instead of two. A designer decides the avatar should sit on the right side rather than the left.

Every one of these requests turns into yet another prop.

A better approach is to expose the layout as composable pieces instead:

<Card>
  <Card.Header>
    <Avatar
      src={user.avatarUrl}
      alt={user.name}
    />
    <div>
      <Card.Title>{user.name}</Card.Title>
      <Card.Subtitle>{user.role}</Card.Subtitle>
    </div>    <Badge variant={user.statusColor}>
      {user.status}
    </Badge>
  </Card.Header>  <Card.Body>
    <p>{user.bio}</p>
  </Card.Body>  <Card.Actions>
    <Button
      variant="secondary"
      onClick={viewProfile}
    >
      View Profile
    </Button>    <Button
      variant="primary"
      onClick={sendMessage}
    >
      Send Message
    </Button>
  </Card.Actions>
</Card>

Now you can actually see the structure of the card in the JSX. And because you can see it, you can change it.

Want one more action button? Drop it in. Need a different badge? Swap it out. Have to add another banner? Insert it wherever it belongs. Want a completely different header arrangement? Just write a new header.

None of this requires touching a bloated shared component to support a single new variant.

4. Composition Beats Configuration

These two approaches ask fundamentally different questions.

Configuration asks:

"What set of options should this component expose?"

Composition asks:

"What small building blocks can consumers assemble into what they actually need?"

Configuration keeps piling more decision-making responsibility onto the component itself. Composition hands those decisions back to whoever is using the component. That's typically a healthier place for that responsibility to live.

Take this comparison as an example:

<Button
  hasLeftIcon
  leftIcon={<SaveIcon />}
  hasTooltip
  tooltipText="Save your profile"
  showSpinner={isSaving}
  spinnerPosition="left"
/>

versus:

<Tooltip content="Save your profile">
  <Button>
    {isSaving && <Spinner />}
    <SaveIcon />
    Save Profile
  </Button>
</Tooltip>

The second snippet has more JSX in it, sure. But it also has far less coupling between pieces that don't need to know about each other. That trade is usually worth making — a bit of extra markup at the call site tends to be cheaper in the long run than bolting one more permanent feature onto a shared component.

5. Learn From Headless Libraries

Several modern frontend libraries demonstrate this philosophy exceptionally well.

Projects like Radix UI, TanStack Table, and shadcn/ui have played a big role in popularizing this composition-first mindset.

TanStack Table is one of the clearest examples. It doesn't lock you into one fixed table design. Instead, it supplies the logic and state management for concerns like:

  • Sorting
  • Filtering
  • Pagination
  • Row selection
  • Column management
  • Table state

Rendering that state into actual markup is entirely up to you. For instance:

<table>
  <thead>
    {table.getHeaderGroups().map((headerGroup) => (
      <tr key={headerGroup.id}>
        {headerGroup.headers.map((header) => (
          <th key={header.id}>
            {flexRender(
              header.column.columnDef.header,
              header.getContext()
            )}
          </th>
        ))}
      </tr>
    ))}
  </thead>
  <tbody>
    {/* Your own rendering logic */}
  </tbody>
</table>

The exact shape of the API isn't really the point here. What matters is the underlying architecture: the table engine owns the behavior, while you own the presentation. Because those two concerns are split apart, you're free to redesign the visuals without ever needing the core table logic to balloon into one giant visual component.

6. Separate Behavior From Appearance

This naturally leads to another guideline worth following:

Keep what a component does separate from how it's rendered.

A single component might legitimately have to juggle a fair amount of internal complexity, covering things such as:

  • Keyboard navigation
  • Focus management
  • Selection
  • Sorting
  • Filtering
  • State
  • Accessibility
  • Positioning

Handling all of that well is rarely trivial. Yet none of it forces the same component to also dictate exactly how the final result should look.

This is the core idea behind most headless UI systems. The behavior gets reused across many visual implementations, while the presentation layer stays completely open. That split typically produces far stronger reusability than trying to build one enormous component packed with dozens of visual configuration knobs.

7. When Props Genuinely Make Sense

None of this means props are a bad pattern.

They're one of the most fundamental tools React gives you.

The issue isn't that props exist. It's using them to encode every conceivable structural variation a component might need.

Examples like these are completely fine:

<Button variant="primary" />
<Button size="sm" />
<Input disabled />
<Card className="featured" />

The red flag shows up once a component starts accumulating a list like this:

<Component
  showHeader
  showFooter
  showBadge
  showIcon
  showActions
  iconPosition="left"
  badgePosition="right"
  footerAlignment="center"
  actionLayout="horizontal"
  compact
  dense
  bordered
  rounded
  disableAnimation
  customHeaderRenderer={...}
  customFooterRenderer={...}
/>

Once you spot that pattern, it's worth pausing to ask:

"Should these really stay as props, or would some of them work better as composed children?"

Just asking that question tends to head off a lot of unnecessary complexity down the line.

8. What Over-Abstraction Actually Costs You

The real damage caused by over-configured components isn't aesthetic.

It's that they become costly to modify.

A widely shared component carries a large blast radius. If twenty places in your codebase depend on it, then every new prop you bolt on has the potential to ripple across all twenty of those consumers.

Engineers grow hesitant to touch it.

Writing documentation for it gets harder.

Testing it gets harder.

Tracking down bugs in it gets harder.

Eventually the component becomes so thoroughly "reusable" that nobody actually wants to reuse it.

That's the irony baked into this pattern.

Over-abstraction can undermine the exact reusability it was meant to deliver.

A small, tightly scoped primitive frequently ends up more reusable in practice than a sprawling component built to anticipate every possible variation.

A Practical Framework for Building Components

Before you reach for another configuration prop, run through these checks.

1. Am I dealing with behavior or with presentation?

If it's behavioral, a prop or a custom hook is probably the right tool. If it's purely about visual structure, composition is usually the better fit.

2. Does this component actually need this information?

If a single component is expected to understand tooltips, analytics hooks, badges, icons, loading indicators, and every arrangement of its children, it has almost certainly taken on more responsibility than it should.

3. Would children handle this instead?

If it would, favor composition over introducing yet another content-shaped prop.

4. Would named slots clarify the API?

For more elaborate components, defining explicit regions like:

<Card.Header />
<Card.Body />
<Card.Footer />

can make the surface area far easier to reason about.

5. Can the logic be pulled apart from the markup?

If so, a headless or logic-first approach is worth considering.

6. Is this prop addressing a real need, or an imagined one?

This question matters more than it seems.

Resist adding something like:

futureFeature={true}

just because someone might conceivably want it down the road.

Design around requirements you actually have evidence for, not ones you're guessing at.

Closing Thoughts

The point of building reusable React components isn't to produce something that can do absolutely anything.

It's to produce pieces that can be assembled in many different ways.

That distinction sounds subtle, but architecturally it changes everything.

A component sitting on thirty props might appear flexible on the surface, but each of those props introduces another responsibility, another state to account for, and another combination someone will eventually have to support.

By contrast, a component with a narrow API and clear composition boundaries might look unassuming.

In practice, though, it tends to hold up far better over time.

So the next time you're tempted to tack another prop onto a shared component, stop for a moment and ask:

"Am I making this more reusable, or just more configurable?"

Those two things are not interchangeable.

A component doesn't become reusable by accumulating thirty options.

It becomes reusable by doing one job well, exposing sensible boundaries, and staying out of the way otherwise.

Solid component architecture isn't about forecasting every future need.

It's about making whatever future needs arise easy to compose.