React Slot/asChild Composition Pattern

Published: |

React Slot/asChild Composition Pattern

In React, we have seen several composition patterns for creating advanced and reusable components, for example, the Higher Order Components (HOCs) and the as prop.

This introductory post explains the recent asChild prop and the Slot pattern popularised by Radix UI, but along the way, we will also see how React APIs like forwardRef and libraries like clsx play together, lastly, we will look into the future with Base UI and its render prop.



The asChild Prop

Let’s say you want to render a Button component as an Anchor a element instead:

<Button>click here</Button>
// output: <button>click here</button>
<Button asChild>
<a href="/about">About</a>
</Button>
// output: <a href="/about">About</a>
<Button asChild={false}>
<a href="/about">About</a>
</Button>
// output: <button><a href="/about">About</a></button>

Why don’t we just build a separate anchor component instead? e.g., a Link component? The above example is simplified, but in a more complex example, we might have properties to inherit from the Button component (styles, analytics, etc), and we just want to extend its ability to render differently.

One simple React implementation of the asChild prop can be like this:

import * as React from "react";
type ButtonProps = React.ComponentProps<'button'> & {
asChild?: boolean
}
const Button = ({ asChild, children, ...props }: ButtonProps) => {
if (asChild) {
// Clone the child element and merge props into it
return React.cloneElement(children as React.ReactElement, props)
}
// Default: render as button
return <button {...props}>{children}</button>
}

Side note: the children prop can be anything in React! Static content, a function, multiple elements, or more…

<Button>Click me</Button>
// children = "Click me"
<Button><span>Hello</span></Button>
// children = <span>Hello</span>
<Button>
{(data) => <div>{data.name}</div>}
</Button>
// children = (data) => <div>{data.name}</div>
<Button>
<Icon />
<span>Click</span>
</Button>
// children = [<Icon />, <span>Click</span>]

Our implementation so far would fail if we changed the component children to different elements:

<Button asChild>
<a>Click</a>
<span>Extra</span>
</Button>
// ❌ Error! cloneElement expects a single ReactElement, not an array
<Button asChild>
Just text
</Button>
// ❌ Error! Can't clone a string

What about other props? e.g., conflicting className?

<Button asChild className="text-red">
<a href="/about" className="text-sm">
About
</a>
</Button>
// ❌ output: <a href="/about" class="text-red">About</a>

The parent element’s className overwritten the child anchor element’s text-sm. That’s because cloneElement(children, props) is a function that essentially does:

cloneElement(
<a href="/about" className="text-sm">About</a>,
{ className: "text-red" }
)

and because the parent className prop comes later, it has overwritten the previous conflicting prop.

We could split the conflicting className string and merge them together, or use the clsx library to do that for us:

Note

People familiar with the stack might also know libraries like tailwind-merge and Class Variance Authority. I won’t go into them here as it’s out of scope. But if you’re interested in a follow-up post about these libraries, let me know!

import { clsx } from 'clsx';
// within the Button component
return React.cloneElement(children as React.ReactElement, {
...props,
className: clsx(children.props.className, props.className),
})
// output: <a href="/about" class="text-sm text-red">click me</a>

Note the order of our classnames! The child element’s classnames are first then the parent’s.

Given the CSS specificity rules, that means if there are conflicting classnames (say parent have text-red and child have text-blue), the latter/parent’s classname will take priority.

If we want the child’s prop to win over the parent’s, a quick fix is to reorder the props and classnames in cloneElement:

return cloneElement(children as React.ReactElement, {
...props,
...children.props,
className: clsx(props.className, children.props.className),
});
// output: <a href="/about" class="text-red text-sm">About</a>

Should child props win over parent’s, the other way around? This is a component design decision. As we’ll see in Radix UI later, they choose that the child’s props should win over parent’s. But you may see libraries that do the opposite. Either way, it is important to communicate clearly (warning or docs) to people which props have the higher priority.

There are still other use cases we haven’t covered, for example:

  • We are not forwarding refs
const buttonRef = React.useRef<HTMLButtonElement>(null)
const anchorRef = React.useRef<HTMLAnchorElement>(null)
<Button asChild ref={buttonRef}>
<a ref={anchorRef} href="/">Link</a>
</Button>
// ❌ both refs would not work!
  • Child event handlers could silent important parent ones
<Button asChild onClick={() => analyticsTracking()}>
<a onClick={() => console.log("child")}>click</a>
</Button>
// ❌ parent onClick would not fire
  • Have we considered other props than className? say the style prop?
  • other edge cases…

We could expand our implementation, or check out the Slot component from Radix UI where they’ve considered and implemented most of the edge cases for us.

The Slot from Radix UI

First, install the @radix-ui/react-slot library

Terminal window
pnpm add @radix-ui/react-slot

Reminder, this is what our implementation looks like so far:

const Button = ({ asChild, children, ...props }: ButtonProps) => {
if (asChild) {
return React.cloneElement(children as React.ReactElement, {
...props,
...children.props,
className: clsx(props.className, children.props.className),
})
}
return <button {...props} />
}

To replicate this with the Slot component from Radix UI:

import { Slot } from '@radix-ui/react-slot'
const Button = ({ asChild, ...props }: ButtonProps) => {
const Comp = asChild ? Slot : 'button'
return <Comp {...props} />
}

With the Slot component from Radix UI, we’ve also handled the following cases:

  • Event handlers
<Button asChild onClick={() => analyticsTracking()}>
<a onClick={() => console.log("child")}>click</a>
</Button>
// we can handle both onClick events

What if the parent component have a onClick event but we only want to fire the child’s one? Usually, we could try event.stopPropagation() but since Radix Slot merges the event handlers together, the stop event propagation method would not work. The workaround is to wrap the parent’s logic in a condition and check against the defaultPrevented property:

<Button
asChild
onClick={(event) => {
if (!event.defaultPrevented) {
console.log('button clicked')
}
}}
>
<a
href="/about"
onClick={(event) => {
event.preventDefault()
console.log('anchor clicked')
}}
>
About
</a>
</Button>
  • Merging props automatically, including other props like style
<Button asChild style={{ padding: '10px' }}>
<a href="/about" style={{ border: '3px solid purple' }}>
About
</a>
</Button>
// the style prop also merged together
  • Multiple components as children with Slot.Slottable:
const Button = ({ asChild, children, leftElement, rightElement, ...props }) => {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp {...props}>
{leftElement}
<Slot.Slottable>{children}</Slot.Slottable>
{rightElement}
</Comp>
);
}

React 18 and forwardRef

Let’s go back to our custom implementation and examine how refs could be merged.

Previously, we mentioned how the following example would not work:

const buttonRef = React.useRef<HTMLButtonElement>(null)
const anchorRef = React.useRef<HTMLAnchorElement>(null)
<Button asChild ref={buttonRef}>
<a ref={anchorRef} href="/">Link</a>
</Button>
// ❌ both refs would not work!

We could extend our custom implementation and merge the refs together:

// Simple utility to merge refs
function composeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
return (node: T | null) => {
refs.forEach((ref) => {
// ref could be a callback function when used in useCallback
if (typeof ref === 'function') {
ref(node)
} else if (ref != null) {
ref.current = node
}
})
}
}
const Button = React.forwardRef(({ asChild, children, ...props }: ButtonProps, forwardedRef) => {
if (asChild) {
const child = children as React.ReactElement
return React.cloneElement(child, {
...props,
...child.props,
ref: forwardedRef ? composeRefs(forwardedRef, child.ref) : child.ref,
className: clsx(props.className, child.props.className),
})
}
return <button ref={forwardedRef} {...props} />
})

To test it in the application:

function App() {
const buttonRef = React.useRef<HTMLButtonElement>(null)
const anchorRef = React.useRef<HTMLAnchorElement>(null)
React.useEffect(() => {
console.log('buttonRef:', buttonRef.current)
console.log('anchorRef:', anchorRef.current)
}, [])
return (
<Button asChild ref={buttonRef}>
<a href="#" ref={anchorRef}>
About
</a>
</Button>
)
}

Notice how we are logging ref.current within useEffect. If we logged the ref earlier, say console.log(forwardRef), it would print {current: null} as React is still in its render phase, and it has yet updated the DOM and assigned Refs

This should print the following in the console:

buttonRef: <a href=​"#">​About​</a>​
anchorRef: <a href=​"#">​About​</a>​

If we set asChild={false}, it would print:

buttonRef: <button><a href=​"#">​About​</a>​</button>
anchorRef: <a href=​"#">​About​</a>​

We can achieve the same with the Slot component from Radix UI:

// Radix UI with React 18
const Button = React.forwardRef(({ asChild, ...props }: ButtonProps, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp {...props} ref={ref} />
})

From React v19, the forwardRef function is deprecated. We can access ref as a prop for function components, so update the Radix Slot code as below to pass the ref:

// Radix UI with React 19
const Button = ({ asChild, ref, ...props }: ButtonProps) => {
const Comp = asChild ? Slot : 'button'
return <Comp {...props} ref={ref} />
}
<Button asChild ref={buttonRef} className="text-red">
<a href="#" ref={anchorRef} className="text-sm">
About
</a>
</Button>

Future: Base UI

Looking ahead, the authors of Radix UI started working on Base UI https://base-ui.com

In Base UI, the Slot pattern would be replaced by the render prop and the useRender hook:

import { useRender } from '@base-ui/react/use-render'
interface ButtonProps extends useRender.ComponentProps<'button'> {}
const Button = ({ render, ...props }: ButtonProps) => {
return useRender({
defaultTagName: 'button',
render,
props,
})
}
<Button
className="text-red"
render={<a href="#" className="text-sm" ref={anchorRef} />}
>
About
</Button>
// output: <a href="#" class="text-sm text-red">About</a>
// buttonRef: <a href="#" class="text-sm text-red">About</a>
// anchorRef: <a href="#" class="text-sm text-red">About</a>

The new render prop is actually an old as prop pattern the React community has seen before: https://bsky.app/profile/haz.dev/post/3ldogub3bt22b

The props merging magic is automatically handled for us, but if we wanted to, Base UI also provides the mergeProps function to allow us to customise further:

import { mergeProps } from '@base-ui/react/merge-props'
const Button = ({ render, ...props }: ButtonProps) => {
return useRender({
defaultTagName: 'button',
render,
// either here
props: mergeProps<'button'>({ className: 'underline' }, props),
})
}
<Button
className="text-red"
render={(props) => (
<a
href="#"
{...mergeProps<'a'>(props, {
className: 'text-sm',
// or here
ref: composeRefs(props.ref, anchorRef),
})}
/>
)}
>
About
</Button>
// output: <a href="#" class="text-sm text-red underline">About</a>
// buttonRef: <a href="#" class="text-sm text-red underline">About</a>
// anchorRef: <a href="#" class="text-sm text-red underline">About</a>

Overall, the Radix UI approach was more implicit because we need to understand the following:

  • the asChild prop actually triggers another Slot component
  • the Slot component’s merging logic is abstracted away

And the Base UI approach is more explicit because:

  • the render prop name is more meaningful (this is what’s getting rendered)
  • the underlying mechanism is a modern React hook useRender
  • the useRender.ComponentProps type utility likely provides better type inference
  • the mergeProps explicitly allow us to control merge behaviours

This reflects the React community’s continuous evolution toward a more explicit, hook-based APIs rather than component-wrapper patterns.

However, the asChild pattern has become somewhat standard in the ecosystem (Radix, shadcn/ui, etc.), it will be a while to see more downstream libraries and developers catch up.

Meanwhile, it is still worth for developers today to understand how both patterns work.