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
variantandsizeoptions 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.