Designing Component APIs
A component’s API is a contract with its consumers. Get it right and teams reach for the component with confidence. Get it wrong and consumers fight the interface, work around its limitations, or stop using it altogether. The quality of its APIs is often what decides whether a component library saves teams time or turns into a maintenance burden.
Knowing the available techniques is only part of it. The harder part is knowing 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.
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. Knowing which category your component fits narrows down the techniques worth considering.
Understand the Core Techniques
Props: The Default
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, and the more complex patterns are only worth reaching for when props alone get awkward.
Slots: Content Projection
Slots let parent components inject content into designated areas of a child component. Every framework supports the idea, though the syntax varies.
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>
{#snippet header()}<h2>Profile Settings</h2>{/snippet}
<p>Update your preferences below.</p>
{#snippet footer()}<Button>Save Changes</Button>{/snippet}
</Card> <custom-card>
<h2 slot="header">Profile Settings</h2>
<p>Update your preferences below.</p>
<button slot="footer">Save Changes</button>
</custom-card> With slots, the parent decides what to render and the child decides where to render it. This split 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 calls {@render renderOption(option, { isHighlighted })} for each option -->
<Autocomplete options={countries}>
{#snippet renderOption(option, { isHighlighted })}
<div class={isHighlighted ? 'highlighted' : ''}>
<Flag code={option.code} /> {option.name}
</div>
{/snippet}
</Autocomplete> Render props work well when the logic is complex but the presentation varies, because the component owns the logic and the consumer owns the markup.
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 give custom components the same kind of API.
<!-- 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 stays similar.
Compound components feel natural when the pieces are always used together. The implicit coordination makes them simpler to use, but it also makes 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 5 uses callback props, replacing the custom events dispatched in Svelte 4. 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. When a value prop is provided, the component ignores its internal state, and when it isn’t, the component manages 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 initialized 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)
Supporting both modes means simple usage works without wiring up state, while more complex usage keeps 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 work is in choosing the right technique for each aspect of the component rather than forcing everything through one pattern.
Props come first. When props feel limiting, slots are the next step. When slots need access to internal state, render props come next, and when multiple pieces need to coordinate implicitly, compound components fit. Each step adds flexibility but also complexity, so the simplest level that serves your consumers is usually the right place to stop.
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 add capabilities without making the simple uses any harder.
<!-- 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
With TypeScript, consumers get autocomplete and type checking instead of finding out by trial and error what a component accepts. 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 make refactoring safer when the API needs to change.
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 the ones the component can’t work without. If there’s a sensible default, the prop can be 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: The simplest API that works for your known use cases is the place to begin. Feedback from consumers then shows what flexibility they need in practice, as opposed to what they might need in theory, and that’s the point to expand the API. Adding to an API later is much easier than taking something away.