Back to blog

December 15, 2024

Designing Components with Explicit APIs

I've been thinking about how I like to build components lately — and yeah, I'm definitely in the "tiny, explicit APIs" camp. If a component takes a huge object of props or spreads them around like butter on toast, I start to feel a little itchy.

This post is kind of a brain dump about how I approach building dev-friendly components and why I think explicit beats flexible 9 times out of 10.


1. Compound Components That Just Make Sense

Some components are like peanut butter and jelly — they only make sense together. If you're building something like a form group or a modal with separate parts, it feels way more natural to wrap them in a parent.

Here's a fake example using a FormGroup:

<FormGroup>
  <FormGroup.Label>Name</FormGroup.Label>
  <FormGroup.Input />
</FormGroup>

This setup:

  • Makes it obvious how stuff connects
  • Reduces prop juggling
  • Gives you a chance to share state internally

If you tried to use FormGroup.Input on its own... well, you'd probably be missing context or styling, which is kind of the point.


2. Enum-Style Props Over Raw Strings

When I expose options like variant or size, I like doing it through static values that hang off the component. Something like this:

<Tag variant={Tag.variant.success} size={Tag.size.medium}>
  Successfully saved!
</Tag>

Yeah, it's more typing than just writing "success" or "medium"... but the payoff is real:

  • You get autocomplete
  • Refactors are safer
  • It's easier for devs to discover valid options

No one's guessing what the available sizes are. It's right there.


3. Avoid Prop Spreading

This is a personal rule. I try really hard not to use {...props} unless there's a good reason. It might make the code a little more verbose, but that tradeoff is totally worth it.

With explicit props:

  • You can see everything a component takes without leaving the file
  • You're forced to be intentional about the API
  • Weird behavior is easier to track down

I've been bitten too many times by sneaky props getting passed to a DOM node or a styled wrapper when they shouldn't.


4. Combine It All for Clean, Clear Usage

Let's take a more real-ish example. Say we're building a custom Alert component with support for variants and dismiss buttons:

<Alert variant={Alert.variant.warning}>
  <Alert.Message>This action is permanent</Alert.Message>
  <Alert.Dismiss onClick={() => setShowAlert(false)} />
</Alert>

This gives us:

  • A compound structure with internal logic handled by the parent
  • A typed and discoverable variant
  • Clear roles for each sub-component
  • No surprise props flying around

🧱 Example: Building a Button Component

A Button is a perfect example of where this API style really shines.

Usage

<Button>
  Default
</Button>

<Button variant={Button.variant.primary} size={Button.size.large}>
  Primary Action
</Button>

<Button variant={Button.variant.outline} size={Button.size.small} disabled>
  Disabled Outline
</Button>

Implementation

import React from 'react'
import clsx from 'clsx'

export function Button({
  children,
  variant = Button.variant.default,
  size = Button.size.medium,
  disabled = false,
  ...rest
}: {
  children: React.ReactNode
  variant?: ButtonVariant
  size?: ButtonSize
  disabled?: boolean
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
  const classes = clsx(
    'inline-flex items-center justify-center rounded font-medium transition',
    {
      'bg-gray-200 text-black': variant === Button.variant.default,
      'bg-blue-600 text-white': variant === Button.variant.primary,
      'border border-gray-400 text-gray-800': variant === Button.variant.outline,

      'text-sm px-3 py-1.5': size === Button.size.small,
      'text-base px-4 py-2': size === Button.size.medium,
      'text-lg px-5 py-3': size === Button.size.large,

      'opacity-50 pointer-events-none': disabled,
    }
  )

  return (
    <button className={classes} disabled={disabled} {...rest}>
      {children}
    </button>
  )
}

// Enum-style static values
type ButtonVariant = 'default' | 'primary' | 'outline'
type ButtonSize = 'small' | 'medium' | 'large'

Button.variant = {
  default: 'default',
  primary: 'primary',
  outline: 'outline',
} satisfies Record<string, ButtonVariant>

Button.size = {
  small: 'small',
  medium: 'medium',
  large: 'large',
} satisfies Record<string, ButtonSize>

✅ Why I Like This

  • You can discover all valid variant and size options right off the component. No need to check docs or guess.
  • The defaults are baked in, but still overrideable.
  • We avoid magic strings floating around.
  • It's easy to refactor since enums are just static values.
  • Even non-TS folks can grok this API quickly.

Wrapping Up

I'm not saying flexible APIs are bad — there's definitely a time and place for them. But for the stuff I build (and the teams I've worked with), I've found that being a little stricter upfront makes everything easier in the long run.

To recap:

  • Favor compound components when the pieces rely on each other
  • Use static values for things like variant, size, direction, etc.
  • Be explicit with props — no hidden surprises
  • Build for clarity, not cleverness

That's my vibe. If you've got a different approach or even a counter-opinion, I'm all ears.