Component API Design That Scales
After fifteen years of building UIs and several years specifically building design systems at Atlassian and Weel, I’ve come to believe that the hardest problem in frontend engineering isn’t state management, rendering performance, or even CSS. It’s designing component APIs that survive contact with other engineers. A component API is a contract. Once someone imports your<Button>, you’re on the hook. Every prop you expose is a promise. Every pattern you establish becomes a precedent. And unlike backend APIs where you can version endpoints, component APIs live inside other people’s code — changing them means changing their code.
Here’s what I’ve learned about getting them right.
The API Surface Area Concept
Every prop you add to a component increases its API surface area. More surface area means more to document, more to test, more edge-case interactions, and more you can never remove without a breaking change. I think of it like this: a component with 3 props has a small surface area — it’s easy to understand, hard to misuse. A component with 30 props is an aircraft cockpit. Most people will use 4 of those props and be confused by the other 26.Naming Conventions That Prevent Arguments
Naming is the first thing that goes wrong. I’ve seen teams spend an hour debatingisDisabled vs disabled vs state="disabled". Here’s the convention I enforce and why.
Boolean props: is / has prefix
is/has prefix makes it unambiguous that the prop is a boolean. Yes, HTML uses disabled without a prefix. But we’re not writing HTML — we’re writing component APIs consumed by engineers who read props in autocomplete dropdowns. isDisabled screams “boolean” in a way disabled doesn’t.
Event handlers: on prefix
on prefix aligns with DOM conventions and React’s own patterns. The handler inside the component can be handleClose, but the prop should always be onClose.
Variants: union types, not booleans
isPrimary and isDanger are true?
Composition vs Configuration
This is the single most important decision in component API design, and it’s the one most teams get wrong early. Configuration means one component with many props:When to use composition
- The component has multiple visual regions (header, body, footer)
- Users need to control rendering of sub-parts
- There are many possible arrangements of child elements
- The component is part of a design system consumed by many teams
When configuration is fine
- The component is leaf-level (no meaningful sub-parts)
- The use cases are well-known and stable
- You’re building an application component, not a library component
Modal with configuration — headerContent, footerContent, bodyContent props. Within six months, teams wanted custom padding in the body, sticky footers, headers with tabs, modals without headers. Every request became a new prop. We eventually rebuilt it as a compound component and the prop count dropped by 60%.
Compound Components Done Right
Compound components are the composition pattern in React. They’re a group of components that work together to form a cohesive whole, sharing implicit state.Rules for compound components
- Shared state goes in context — not prop drilling
- Each sub-component should work only inside its parent — throw a helpful error otherwise
- Expose a hook for advanced consumers —
useTabsContext()lets people build custom tab components - Namespace exports —
Tabs.List,Tabs.Tab,Tabs.Panelor separate named exports
Polymorphic Components: The as Prop
Sometimes a component needs to render as different HTML elements or even other components. A Button might need to be an <a> when it links somewhere. A Box might need to be a <section> or <article>.
as value. If you pass as="a", TypeScript knows href is valid. If you pass as="button", it knows type is valid.
When polymorphism goes wrong
I’ve seen teams make everything polymorphic. Don’t. Polymorphism adds cognitive load and makes components harder to document and test. Use it when:- The component is a primitive (Box, Text, Flex) that genuinely maps to multiple elements
- You need semantic HTML flexibility without duplicating styling logic
- The consumer legitimately needs to swap the underlying element
Modal is always a dialog. A Checkbox is always an input.
Render Props vs Hooks vs Composition
The evolution of React patterns has given us three ways to share behavior:| Pattern | Best For | Trade-off |
|---|---|---|
| Render props | Passing data to flexible UI | Nesting hell with multiple render props |
| Custom hooks | Sharing stateful logic | Consumer must handle rendering |
| Composition | Sharing UI + behavior | More components to understand |
When to Break Components Apart
This is more art than science, but here are the signals I look for:Split when you see these
- The component file is over 300 lines — size is a code smell for components
- Props are being passed through 3+ levels — you need context or composition
- You’re using
childrenand alsoheaderContent,footerContent— this is configuration trying to be composition - The component has multiple
useEffectcalls managing different concerns — each concern should be its own hook or sub-component - Conditional rendering is nested 3+ levels deep — each branch is probably its own component
Don’t split when
- The “components” would always be used together — don’t create
ButtonIconandButtonLabelif nobody would use them independently - The state is tightly coupled — splitting just moves the complexity to the communication between components
- You’re splitting to reduce file size, not to reduce complexity — a 200-line component that does one thing well is fine
Prop Defaults and the Principle of Least Surprise
Every prop should have a sensible default that represents the most common use case. If 80% of users will passvariant="primary", make "primary" the default.
Note that
type defaults to 'button', not 'submit'. In HTML, the default is 'submit', which causes unexpected form submissions. Overriding this default prevents one of the most common button-in-form bugs.The Escape Hatch Pattern
No matter how well you design your API, someone will need to do something you didn’t anticipate. Build in escape hatches:...rest onto the root element means consumers can add data-testid, aria-* attributes, event handlers, or anything else without you needing to anticipate it. It’s the single most important escape hatch in component design.
Lessons From the Trenches
After building design system components used by hundreds of engineers across Atlassian and Weel, here’s what sticks with me:- Your first API will be wrong. Ship it, watch how people use it, then iterate. Don’t design in a vacuum.
- Deprecation is better than breakage. When you need to change an API, add the new prop alongside the old one, emit a console warning, and give teams a migration window.
- Documentation is part of the API. If you can’t explain a prop in one sentence, the prop is too complex.
- Study Radix UI, Headless UI, and React Aria. These libraries represent years of API design thinking. You don’t need to invent patterns — you need to adopt proven ones.
- Every prop is a maintenance burden forever. Before adding a prop, ask: “Will I regret this in two years?” If the answer is maybe, don’t add it.
