Files
v10/.claude/skills/component/references/polymorphism.md
T

166 lines
5.0 KiB
Markdown

# 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
```tsx
// 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
```tsx
// 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
```tsx
<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:
```tsx
// 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:
1. Spread all props it receives
2. Forward refs correctly
3. 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](https://github.com/mui/base-ui/blob/master/packages/react/src/merge-props/mergeProps.ts)
---
## Avoiding the `as` Prop
The `as` prop (polymorphic components) has TypeScript performance issues:
```tsx
// 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](../../api-design/principles/progressive-disclosure.md) — layered complexity
- [Anti-Patterns](anti-patterns.md) — polymorphism pitfalls