A component API is a promise. Every prop you ship is a contract you'll either keep, deprecate carefully, or break someone's day. Most React component APIs I've seen in production codebases drift the same way over time — a useful little component slowly accumulating boolean flags, edge-case props, and "oh just one more variant" until the type signature is two screens tall and nobody can tell what does what.

This article is about avoiding that. The patterns that make components flexible enough to grow, focused enough to stay understandable, and durable enough to not break consumers every quarter.

Start Small. Stay Small.

The first version of a component should accept the fewest possible props. If you can't predict whether something will be configurable, leave it out. Adding a prop later is easy; removing one is a breaking change.

JSX
// First pass — three props, all earned
<Card title="Account">
  <p>Some content</p>
</Card>

A Card doesn't need padding, bordered, shadow, headerVariant, accentColor on day one. Those props arrive when a real second use case shows up, not because you imagined one might.

The implicit cost of a prop isn't just the line of code. Every prop is one more thing to document, one more thing in autocomplete, one more thing the next person has to read before they understand the component. Twenty props on a single component is almost always a sign that the component is doing two jobs and should be split.

The Boolean Prop Trap

The fastest way to ruin a component API is to add boolean props for every variant:

JSX
// ❌ The boolean trap
<Button primary />
<Button primary large />
<Button primary large outlined />
<Button primary large outlined disabled />
<Button danger />
<Button danger large outlined />

What does <Button primary danger> do? <Button primary outlined ghost>? Booleans don't compose — you've created an exponential matrix of states, and most of them aren't meaningful.

The replacement is enums via string props:

JSX
// ✅ Single string for variant, single string for size
<Button variant="primary" size="md">Save</Button>
<Button variant="ghost" size="sm">Cancel</Button>
<Button variant="danger">Delete</Button>

Mutually exclusive options become mutually exclusive types. The type system can enforce it. The matrix of states collapses to a clear product.

For TypeScript, this is where discriminated unions earn their keep:

TypeScript
type ButtonProps =
  | { variant: 'primary' | 'secondary' | 'danger'; href?: never }
  | { variant: 'link'; href: string };

Pass variant="link" and the compiler now requires href. Pass variant="primary" and href is forbidden. The component's invariants are enforced before runtime.

Composition Beats Options

When a component starts growing prop-shaped tentacles for "header text", "header icon", "header action", "header subtitle" — that's the universe asking you to use children:

JSX
// ❌ Options that imitate composition
<Card
  title="Settings"
  subtitle="Manage your preferences"
  icon={<GearIcon />}
  action={<Button>Save</Button>}
>
  …
</Card>

// ✅ Composition — let the consumer arrange it
<Card>
  <Card.Header>
    <GearIcon />
    <Card.Title>Settings</Card.Title>
    <Card.Subtitle>Manage your preferences</Card.Subtitle>
    <Button>Save</Button>
  </Card.Header></Card>

The composition version doesn't grow when someone needs a "second action" or a "header badge" — they just put it inside Card.Header. The component becomes a structure, not a configurator. (This is the compound components pattern; we'll cover it in detail in another article.)

The Slot Pattern

When you want users to swap a single internal element — say, the trigger of a tooltip or the icon of a button — the slot pattern works well. Two common shapes:

Render-as-prop:

JSX
function Tooltip({ trigger, content }) {
  return (
    <Popover>
      <Popover.Trigger>{trigger}</Popover.Trigger>
      <Popover.Content>{content}</Popover.Content>
    </Popover>
  );
}

<Tooltip
  trigger={<Button>Hover me</Button>}
  content="This is a tooltip"
/>

asChild (popularised by Radix):

JSX
<Tooltip>
  <Tooltip.Trigger asChild>
    <Link href="/help">Need help?</Link>     {/* the link is the trigger */}
  </Tooltip.Trigger>
  <Tooltip.Content>Click for docs</Tooltip.Content>
</Tooltip>

asChild clones the child element and merges in the props (event handlers, ARIA attributes) the parent needs. The consumer keeps full control over what tag and what styles get rendered. It's slightly magical, but once you're used to it, it solves the "I want my button to be a link" problem cleanly.

A two-card comparison: left, a Card with eight props for header configuration; right, the same Card using compound subcomponents. Both render the same UI but only the right one extends without growing the prop list.
Composition scales without expanding the prop list. Options don&#39;t.

Escape Hatches

Even the cleanest API can't anticipate every consumer's need. The escape hatches that consistently age well:

className — accept it, merge it with your internal classes:

JSX
import { cn } from '@/lib/utils';

function Button({ className, variant = 'primary', ...props }) {
  return (
    <button
      className={cn(buttonStyles({ variant }), className)}
      {...props}
    />
  );
}

The consumer can override or extend any styles. className is the single most important escape hatch in any component library.

...rest to the underlying element — pass through HTML attributes you don't explicitly handle:

JSX
<Button onClick={...} aria-label="Save" data-testid="save" disabled>

None of those are props you defined; all of them work because they're spread to the <button>. Resist the urge to "filter" them. The HTML attribute set is huge, and your component shouldn't need to know about every one.

Forwarded refs — components that wrap an interactive element should forward refs:

JSX
const Button = React.forwardRef(function Button({ children, ...props }, ref) {
  return <button ref={ref} {...props}>{children}</button>;
});

Without forwardRef, a consumer who needs a ref to your button (for focus, for measurement, for a third-party library) can't get one. In React 19, ref is just a regular prop on function components, so this gets simpler:

JSX
function Button({ ref, children, ...props }) {
  return <button ref={ref} {...props}>{children}</button>;
}

Either way, the principle is the same: don't trap consumers inside your wrapper.

Controlled, Uncontrolled, Or Both?

A component that holds state should usually let the consumer take it back. The standard pattern: support a value (controlled) but fall back to internal state if not provided.

JSX
function Tabs({ value, defaultValue, onValueChange, children }) {
  const [internal, setInternal] = useState(defaultValue);
  const isControlled = value !== undefined;
  const current = isControlled ? value : internal;

  const handleChange = (next) => {
    if (!isControlled) setInternal(next);
    onValueChange?.(next);
  };

  return (
    <TabsContext.Provider value={{ value: current, onChange: handleChange }}>
      {children}
    </TabsContext.Provider>
  );
}
JSX
// Uncontrolled — Tabs manages its own state
<Tabs defaultValue="general">…</Tabs>

// Controlled — consumer manages it (e.g. URL-driven)
<Tabs value={tab} onValueChange={setTab}></Tabs>

Same component, both modes supported. Most consumers use uncontrolled; the few who need controlled (URL sync, form integration) get it without a different component.

TypeScript Patterns That Pay Off

Three patterns that quietly improve component APIs:

Discriminated unions for mutually exclusive variants (already covered above).

HTMLAttributes<T> for forwarding — extend the props of the underlying element:

TypeScript
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: 'primary' | 'ghost' | 'danger';
  size?: 'sm' | 'md' | 'lg';
};

Consumers get autocomplete for onClick, disabled, type, aria-*, etc., without you re-declaring them.

Polymorphic as typing — when a component can render as multiple HTML elements:

TypeScript
type AsProp<C extends React.ElementType> = { as?: C };
type Props<C extends React.ElementType> = AsProp<C> &
  Omit<React.ComponentPropsWithoutRef<C>, keyof AsProp<C>>;

function Box<C extends React.ElementType = 'div'>(
  { as, ...props }: Props<C>
) {
  const Component = as || 'div';
  return <Component {...props} />;
}
JSX
<Box as="section" aria-label="Hero">…</Box>
<Box as="a" href="/foo"></Box>

This is fiddly to write the first time. Most teams reach for libraries (@radix-ui/react-slot, the as prop in MUI/Chakra) rather than rolling their own. But the pattern itself is what makes a single component flex without becoming five.

Default Behaviour That Doesn't Surprise

Some habits that prevent footguns:

  • type="button" on every internal <button> unless it's literally a submit button. Otherwise your in-form button accidentally submits the form.
  • Stop event propagation only when needed. Calling e.stopPropagation() by default breaks composition; consumers who wanted the event to bubble can't get it back.
  • Don't e.preventDefault() in event handlers users pass in. That's their decision, not yours.
  • Don't auto-focus aggressively. Auto-focusing the first input on every modal open is a common a11y annoyance.
  • Be honest about loading and disabled. A button that "looks disabled" but isn't — or one that's disabled but doesn't say why — is the worst kind of invisible UX.

Versioning A Component API

Once consumers depend on you, breaking changes get expensive. Two habits that have saved me:

  • Mark deprecations early. @deprecated JSDoc, console warnings in dev, keep the old API working for one major version.
  • Ship codemods for renames. jscodeshift is your friend when tone becomes variant.

In a monorepo, you can often migrate consumers in lockstep. In a published package, you can't — assume someone is on the version you released last year.

A One-Sentence Mental Model

A good component API has a small surface, predictable composition, sensible defaults, and visible escape hatches. Each of those four pulls in a different direction; balancing them is the actual skill.

If you find yourself adding a fifth boolean to a component that already has six, stop. The right move is usually to compose smaller pieces, expose a slot, or split into two components — not to keep growing the surface. Boring APIs age the best. The most-used component in your library should be the one with the fewest exciting props.