5.0 KiB
Polymorphism Patterns
Patterns for rendering component behavior on custom elements.
Overview
Polymorphism allows users to customize which element a component renders as. Two main approaches:
| Pattern | Library | Approach |
|---|---|---|
render prop |
Base UI | Explicit function or element |
asChild |
Radix | Clone child element |
Recommendation: Prefer render prop for explicit state access and clearer prop flow.
render Pattern (Preferred)
Two forms: element and function.
Element Form — Simple Cases
// Renders Button with PlayButton behavior — clones element, merges props
<PlayButton className="play-btn" render={<Button />}>
<PlayIcon />
</PlayButton>
// Pass props to the render target directly
<PlayButton className="play-btn" render={<Button variant="icon" />}>
<PlayIcon />
</PlayButton>
The headless component clones the element and merges its own props onto it.
Function Form — State Access or Different Element
// Access internal state for conditional rendering
<Switch.Thumb
render={(props, state) => <span {...props}>{state.checked ? <CheckedIcon /> : <UncheckedIcon />}</span>}
/>
// Render a fundamentally different element type
<BufferingIndicator
render={(props) => (
<div {...props} className="buffering">
<Spinner />
</div>
)}
/>
When to Use Which
| Scenario | Form |
|---|---|
| Simple element swap | render={<Component />} |
| Render target needs its own props | render={<Component prop="..." />} |
| Need internal state access | render={(props, state) => ...} |
| Rendering a different element type | render={(props) => <div {...props}>...} |
Do not pass component references directly (render={Component}). React calls render functions as plain functions, which breaks hooks reconciliation. Always use element form (render={<Component />}) or function form.
asChild Pattern
Usage
<Dialog.Trigger asChild>
<MyButton size="md">Open dialog</MyButton>
</Dialog.Trigger>
Why render > asChild
| Concern | render |
asChild |
|---|---|---|
| Prop flow | Explicit — element or function forms | Hidden — cloneElement merges implicitly |
| State access | Function form exposes component state | No state access |
| TypeScript | Predictable inference | Can slow IDE autocomplete |
| Debugging | Traceable prop flow | Magic makes tracing difficult |
| React guidance | Aligns with React docs | Uses cloneElement (React warns against) |
The Problem with asChild
React's documentation warns that cloneElement "is uncommon and can lead to fragile code" and makes "it hard to tell how the data flows through your app."
asChild hides complexity rather than eliminating it:
// asChild — implicit prop injection
<Dialog.Trigger asChild>
<Button>Open</Button> {/* Which props does Button receive? */}
</Dialog.Trigger>
// render — explicit prop handling
<Dialog.Trigger render={<Button />}>
Open
</Dialog.Trigger>
With asChild, the child component must:
- Spread all props it receives
- Forward refs correctly
- Handle event handler merging
Nothing enforces these requirements at compile time — breakage is silent.
Prop Merging
Both patterns need to merge props carefully:
| Type | Behavior |
|---|---|
| Event handlers | Chain — both called |
className |
Concatenate |
style |
Shallow merge |
| Other props | Consumer overrides |
Reference: Base UI mergeProps
Avoiding the as Prop
The as prop (polymorphic components) has TypeScript performance issues:
// BAD: slow TypeScript, poor autocomplete
<Button as="a" href="/home">Link</Button>
// GOOD: use render prop instead
<Button render={<a href="/home" />}>Link</Button>
The as prop requires complex generic types that slow down the TypeScript language server.
When to Use Each
| Scenario | Pattern |
|---|---|
| Simple element swap | asChild acceptable |
| State-based rendering | render (function form) |
| Complex prop merging | render (explicit control) |
| Debugging issues | render (visible prop flow) |
| Maximum type safety | render |
See Also
- Progressive Disclosure — layered complexity
- Anti-Patterns — polymorphism pitfalls