Designing Component APIs
A component’s API is a contract with its consumers. Get it right and the component becomes a building block that teams reach for confidently. Get it wrong and consumers fight the interface, work around its limitations, or avoid using it altogether. The difference between a well-designed API and a poorly-designed one often determines whether a component library becomes a productivity multiplier or a maintenance burden.
The challenge isn’t just knowing the available techniques. The challenge is understanding when each approach fits and how they combine. Props, slots, render props, compound components, and event callbacks all solve different problems. Choosing the right combination requires thinking carefully about what consumers need to control and what the component should manage internally.
The patterns in this guide apply across frameworks. React, Vue, Svelte, Angular, and web components all support these concepts, though the syntax and naming conventions differ. Where framework-specific considerations matter, they’re noted. The underlying design principles remain consistent regardless of which framework you use.
Assess Your Component’s Purpose First
Before designing an API, consider what the component is responsible for:
- Presentation components render data they receive. Their API is mostly about configuring appearance: which variant, which size, which content. A Button component falls into this category.
- Container components manage state and coordinate children. Their API focuses on data flow and behavior: what data to display, what happens when users interact. A DataTable or Form component fits here.
- Layout components arrange other components spatially. Their API needs to accept arbitrary content and position it. A Card with header, body, and footer slots is an example.
- Behavior components add functionality to children without rendering visible elements. A tooltip trigger or focus trap works this way.
Each type benefits from different API patterns. Understanding what category your component fits helps narrow down which techniques will serve consumers best.
Understand the Core Techniques
Props: The Foundation
Props pass data and configuration from parent to child. Every component has them, and for many components they’re sufficient on their own.
Use props when:
- The component needs simple values: strings, numbers, booleans
- Configuration options have a finite, known set of values
- The data flows in one direction from parent to child
Keep props manageable: Components with more than seven or eight props become difficult to use. When you find yourself adding many props, consider whether some can be grouped into objects, whether the component is doing too much, or whether a different pattern like slots would work better.
<!-- Props work well for finite configuration -->
<Button variant="primary" size="large" disabled>
Submit
</Button>
Props are the right default. Only reach for more complex patterns when props alone create friction.
Slots: Content Projection
Slots let parent components inject content into designated areas of a child component. The concept is universal; the syntax varies by framework.
Use slots when:
- The component’s structure is fixed but the content varies
- Parents need to control what gets rendered, not just how
- You want to avoid creating many component variants
Choose between single and multiple slots: A simple Modal might only need one slot for its content. A Card typically needs three: header, body, and footer. Think about which regions of your component consumers will want to customize.
<Card
header={<h2>Profile Settings</h2>}
footer={<Button>Save Changes</Button>}
>
<p>Update your preferences below.</p>
</Card> <Card>
<template #header><h2>Profile Settings</h2></template>
<p>Update your preferences below.</p>
<template #footer><Button>Save Changes</Button></template>
</Card> <Card>
<h2 slot="header">Profile Settings</h2>
<p>Update your preferences below.</p>
<Button slot="footer">Save Changes</Button>
</Card> <custom-card>
<h2 slot="header">Profile Settings</h2>
<p>Update your preferences below.</p>
<button slot="footer">Save Changes</button>
</custom-card> Slots invert control over content. The parent decides what to render; the child decides where to render it. This separation often leads to more reusable components than props alone would allow.
Render Props: Delegated Rendering
Render functions take slots further by letting the child pass data back to the parent during rendering. The parent receives information from the child and decides how to display it.
Use render props when:
- The child has data or state the parent needs for rendering
- You want consumers to control the presentation completely
- The same logic applies to many different visual representations
Recognize the pattern: A virtualized list knows which items are visible but shouldn’t dictate how each item looks. A dropdown knows which option is highlighted but lets consumers style the highlight. In both cases, the component does work and shares results through a render function.
<Autocomplete
options={countries}
renderOption={(option, { isHighlighted }) => (
<div className={isHighlighted ? 'highlighted' : ''}>
<Flag code={option.code} /> {option.name}
</div>
)}
/> <Autocomplete :options="countries">
<template #option="{ option, isHighlighted }">
<div :class="{ highlighted: isHighlighted }">
<Flag :code="option.code" /> {{ option.name }}
</div>
</template>
</Autocomplete> <Autocomplete
options={countries}
renderOption={(option, { isHighlighted }) => (
<div class={isHighlighted ? 'highlighted' : ''}>
<Flag code={option.code} /> {option.name}
</div>
)}
/> Render props excel when the logic is complex but the presentation varies. They separate the “what” from the “how” cleanly.
Compound Components: Implicit Coordination
Compound components work together through shared context. The parent manages state internally while children communicate with it implicitly rather than through explicit props passed at every level.
Use compound components when:
- Multiple related elements need to coordinate state
- The relationship between pieces is hierarchical and well-defined
- You want an API that reads like composable markup
Think of HTML as inspiration: <select> and <option> coordinate implicitly. <table>, <thead>, <tbody>, and <tr> work together without explicit wiring. Compound components bring this ergonomic pattern to custom components.
<!-- Compound components coordinate implicitly -->
<Tabs>
<TabList>
<Tab>Profile</Tab>
<Tab>Settings</Tab>
<Tab>Notifications</Tab>
</TabList>
<TabPanel>Profile content</TabPanel>
<TabPanel>Settings content</TabPanel>
<TabPanel>Notifications content</TabPanel>
</Tabs>
The naming convention varies: React libraries like Radix use dot notation (Tabs.List, Tabs.Panel) to namespace related components, while Vue and Svelte use separate imports with a shared prefix. The implementation mechanism also varies—React uses context, Vue uses provide/inject, Svelte uses context or stores—but the consumer-facing API remains similar.
Compound components feel natural when the pieces are always used together. The implicit coordination simplifies usage at the cost of making the relationship between components less obvious.
Event Callbacks: Communicating Upward
Event callbacks let children notify parents when things happen. They’re the primary mechanism for two-way communication in most frameworks.
Design callbacks deliberately: Consider what information the parent needs. A change callback might pass just the new value, or it might include the event object, or it might provide both. Document the signature clearly.
Follow your framework’s naming conventions: React uses onEventName props. Vue uses @event-name listeners with $emit. Svelte dispatches custom events or accepts callback props. Angular uses (eventName) output bindings. Whatever the syntax, the design question is the same: what data does the parent need when this event occurs?
// Define what data each callback provides
interface DataTableProps<T> {
data: T[];
onRowClick?: (row: T, index: number) => void;
onSelectionChange?: (selected: T[]) => void;
onSort?: (column: keyof T, direction: 'asc' | 'desc') => void;
}
The Event Handler Contract pattern describes how to type callbacks for safety. When consumers don’t have to guess what arguments a callback receives, integration becomes straightforward.
Controlled vs Uncontrolled: Who Owns the State?
Some components can work in either controlled or uncontrolled modes. In controlled mode, the parent manages state and the component reflects it. In uncontrolled mode, the component manages its own state internally.
Support both when practical: An input can accept a value prop for controlled usage or work with defaultValue for uncontrolled usage. A disclosure component can accept isOpen and onToggle or manage open/closed state internally.
Be explicit about which mode is active: Components that accidentally mix controlled and uncontrolled behavior confuse consumers. If a value prop is provided, ignore internal state. If it isn’t, manage state internally.
The implementation pattern looks similar across frameworks:
- Accept both a controlled value prop (
isOpen) and an initial value prop (defaultOpen) - Maintain internal state initialised from the default
- Check whether the controlled prop is provided to determine the mode
- Use the controlled value when provided, internal state otherwise
- Always call the change callback, but only update internal state in uncontrolled mode
// Pseudocode for controlled/uncontrolled support
component Disclosure(isOpen, onToggle, defaultOpen = false):
internalOpen = state(defaultOpen)
isControlled = isOpen !== undefined
currentOpen = isControlled ? isOpen : internalOpen
handleToggle():
if not isControlled:
internalOpen = !currentOpen
onToggle?.(!currentOpen)
This flexibility serves different use cases. Simple usage works without wiring up state. Complex usage retains full control. Vue’s v-model and Svelte’s two-way bindings provide syntactic sugar for controlled mode, but the underlying pattern remains the same.
Combine Techniques Thoughtfully
Real components often use multiple patterns together. A DataTable might use:
- Props for configuration: pagination, sorting options, loading state
- Slots for custom cell content: actions column, expandable rows
- Render props for complete cell customization when slots aren’t enough
- Compound components if the table has separately-composable parts like headers and footers
- Events for interaction: row selection, sorting changes, page navigation
The skill lies in choosing the right technique for each aspect of the component rather than forcing everything through one pattern.
Start with props. When props feel limiting, consider slots. When slots need access to internal state, consider render props. When multiple pieces need to coordinate implicitly, consider compound components. Each step adds flexibility but also complexity. Stop at the simplest level that serves your consumers.
Design for Common Cases
Good APIs make the common case easy and the complex case possible.
Use sensible defaults. Default props mean consumers only specify what they want to change. A Button should work with just children; variant, size, and other options should have reasonable defaults.
Layer complexity. The simplest usage should require the fewest props. Additional props unlock additional capabilities without burdening simple uses.
<!-- Simple usage works with minimal props -->
<Select :options="countries" @change="setCountry" />
<!-- Complex usage adds props only when needed -->
<Select
:options="countries"
:value="country"
@change="setCountry"
placeholder="Select a country"
:filter-fn="(option, query) => option.name.toLowerCase().includes(query)"
:group-by="(option) => option.region"
>
<template #option="{ option }">
<Flag :code="option.code" /> {{ option.name }}
</template>
</Select>
Consumers doing basic things shouldn’t need to understand the advanced features. Consumers doing complex things should find the escape hatches they need.
Type Your Interfaces
TypeScript transforms component APIs from trial-and-error to autocomplete-driven development. The Component Interface pattern describes this in detail.
Type props completely. Required props, optional props, union types for constrained values, explicit callback signatures. The type definition becomes documentation that IDEs can use.
Use generics for data-driven components. A Generic Component like a List or Table can accept items of any type while maintaining type safety for callbacks and render props. When consumers pass User[] as data, TypeScript knows that onRowClick receives a User.
// Generic interface works across frameworks
interface TableProps<T> {
data: T[];
columns: Array<{
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => unknown;
}>;
onRowClick?: (row: T) => void;
}
// Usage: TypeScript infers T from the data prop
// Table<User> when data is User[]
// Table<Product> when data is Product[]
Well-typed APIs catch mistakes at compile time instead of runtime. They also enable confident refactoring when the API needs to evolve.
Quick Decision Guide
| Consumer Need | Recommended Approach | Example |
|---|---|---|
| Configure appearance or behavior | Props | variant, size, disabled |
| Inject custom content | Slots | Card header and footer |
| Control rendering with access to internal state | Render props | Custom list item rendering |
| Compose related elements that coordinate | Compound components | Tabs with TabList and TabPanels |
| Respond to interactions | Event callbacks | onClick, onChange |
| Sometimes manage state, sometimes not | Controlled/uncontrolled | Form inputs |
| Work with different data types | Generics | Table<User> vs Table<Product> |
Signs Your API Needs Adjustment
Watch for friction that indicates the current API isn’t serving consumers:
- Too many props: If the component has fifteen props and growing, consider whether slots or compound components would work better. The proliferation suggests the component is doing too much or props are the wrong abstraction.
- Frequent feature requests for variants: If consumers keep asking for new props to handle slight variations, slots might give them the flexibility they need without changing the component.
- Boilerplate at every usage site: If consumers repeat the same configuration everywhere, defaults might be wrong or the API might be too low-level.
- Type errors or runtime surprises: If consumers regularly pass wrong types or get unexpected behavior, the API’s contract isn’t clear enough. Better typing and naming help.
- Wrapper components proliferating: If consumers create thin wrappers just to set default props or inject common content, the base component’s defaults might need adjustment.
Decision Checklist
Before finalizing a component’s API, work through these questions:
What must consumers provide? Required props should be genuinely required. If there’s a sensible default, make the prop optional.
What should consumers control? Identify which aspects vary across use cases. Those become the customization points. Everything else can be internal.
How will consumers provide custom content? If content varies, choose between props for simple values, slots for markup, or render props when the component has data to share.
What state should the component manage? Consider whether the component should always be controlled, always uncontrolled, or support both modes.
What events need to communicate upward? Define callbacks for meaningful interactions. Type them explicitly.
Does the API scale down to simple use cases? The minimal usage should work with minimal configuration. Don’t burden simple uses with complexity meant for advanced cases.
Next steps: Start with the simplest API that works for your known use cases. Gather feedback from consumers. Expand the API when you understand what flexibility they actually need rather than what they might theoretically need. An API can grow; shrinking one is harder.