Blog

Part 43 · The asChild Pattern in React

Make one Button that can also be a link. Learn the as prop, the asChild pattern, how to build a small Slot that merges props, and when to use each.

Most apps have a Button component with one look: the same color, size and shape everywhere. Then one day a “button” must take the user to another page. That is a link’s job. How can a link look and act like your Button without copying it?

This part answers that question. In Part 3 we passed all props at once with spread. In Part 38 we met Radix UI, a library of ready-made parts. In Part 41 we saw that a ref is a normal prop in React 19.

We’ll use all three here. First we try two wrong ways. Then we try two better ones: the as prop, and the asChild pattern that Radix UI made popular. We’ll build a small Slot component, the piece that makes asChild work. At the end we compare the choices.

Try this first

This Button draws a <button>. We want a link that looks like the button, so we put an <a> inside it. A button under it shows the HTML that React put on the page.

The link’s onClick calls e.preventDefault(). That stops the link from opening, so the result box stays on this page. Part 37 explained why the box needs that.

Read the code, but don’t press Run yet.

import { useRef, useState, type ComponentProps } from 'react'

function Button(props: ComponentProps<'button'>) {
  return <button className="btn" {...props} />
}

export default function App() {
  const boxRef = useRef<HTMLDivElement>(null)
  const [html, setHtml] = useState('')

  return (
    <div>
      <div ref={boxRef}>
        <Button>
          <a href="/pricing" onClick={e => e.preventDefault()}>See prices</a>
        </Button>
      </div>
      <button onClick={() => setHtml(boxRef.current?.innerHTML ?? '')}>Show the HTML</button>
      <pre>{html}</pre>
    </div>
  )
}

Make two guesses. What HTML will the page have? And how many times must you press Tab to reach “Show the HTML”?

Now press Run, then press “Show the HTML”.

The page has two tags, one inside the other:

<button class="btn"><a href="/pricing">See prices</a></button>

The answer is three. Click in the result box, above the button, and press Tab. The focus goes to the button first, then to the link inside it, and only then to “Show the HTML”. So “See prices” is two stops, not one. We checked this in Chromium, Firefox and WebKit, and all three gave the same two stops.

A note on our browser tests. Chromium is the engine inside Chrome, and WebKit is the engine inside Safari. We ran all three without a screen, on Linux, with their default settings. On a Mac, Tab can work differently. The W3C’s guide says that on a Mac, Tab moves only between form parts, not links. A setting can change that. So on a Mac you may see fewer Tab stops.

ComponentProps<'button'> is the type for “every prop a <button> can take”. Part 41 used the same idea for an <input>.

A link, <a href>, takes you somewhere: another page, or another place on this page. Because it has an address, the browser can do more with it. You can open it in a new tab, or copy its address.

A button, <button>, does something here: it saves a form, or opens a menu. It has no address.

The keyboard treats them differently too. The W3C’s guide to common page parts lists the keys for each one. For a button, both Space and Enter work it. For a link, it lists Enter, and not Space. MDN also says Enter works a link.

So a “button” that takes you to another page should really be an <a> that looks like a button. A router’s Link, like the one we built in Part 37, also draws an <a>.

Wrong way 1: one inside the other

HTML has rules about what can go inside what. MDN lists them for each tag, under “Permitted content”. For <button>, it says “there must be no Interactive content” inside. Interactive content means things you can click or use, like links, buttons and inputs. For <a>, MDN says that “no descendant may be interactive content or an <a> element”. A descendant is anything inside a tag, at any depth.

So <button><a> breaks the rules, and so does <a><button>. But nothing stops you:

  • React printed no warning. We rendered both shapes with React 19.3. React does warn about some shapes. For an <a> inside an <a>, it printed In HTML, <a> cannot be a descendant of <a>. But for these two it said nothing.
  • The browser kept them as they were. We loaded both shapes as plain HTML in Chromium, Firefox and WebKit. The page kept the nesting, with no change.

What goes wrong, then? We measured that in the same three browsers.

Two Tab stops. The keyboard user meets the same thing twice, as you saw in “Try this first”.

One mouse click runs both. A click on the words “See prices” ran both click handlers, the inner tag’s first. The link also opened. That was true for both shapes. So the button’s job and the link’s job both happen.

The keys don’t agree. Take the link inside a button, with the focus on the link. Enter opened the link and also ran the button’s click handler. Space was worse. In Chromium and WebKit, Space on the link clicked the outer button. In Firefox, Space did nothing at all. In the button inside a link, Enter or Space on the button opened the link too.

A checker catches one shape. The tool axe-core checks a page for accessibility problems. Its name is usually written “axe”. Accessibility means everyone can use the page, including people who use only a keyboard or a screen reader. A screen reader is a program that reads the page out loud.

We ran axe-core 4.14.0 on both shapes. For the link inside a button, it reported a “serious” problem named nested-interactive: “Interactive controls must not be nested”. Its reason was that they “are not always announced by screen readers or can cause focus problems”. For the button inside a link, it reported nothing.

Why only one? We read axe’s code. This rule only looks inside tags whose children don’t count on their own, like a button. A screen reader reads a button’s inside as one name. A link is not on axe’s list for this rule, so axe doesn’t look inside it. So a clean axe report doesn’t prove the HTML is right.

Wrong way 2: copy the look

The next idea is to give the <a> the same look by hand:

import type { CSSProperties } from 'react'

const buttonLook: CSSProperties = { padding: '6px 12px', borderRadius: 6, background: 'teal', color: 'white' }

export const save = <button style={buttonLook}>Save</button>
export const prices = <a href="/pricing" style={buttonLook}>See prices</a>

This works for a while. But a real Button grows. It gets sizes, colors, an icon, a “loading” state and a disabled look. Each time, every place that copies the look must change too. If you forget one, the link stops looking like the button.

We want one component that owns the look and lets the caller choose the tag.

The as prop

The first fix is a prop that says which tag to draw. Let’s call it as:

import type { ComponentPropsWithoutRef, ElementType } from 'react'

type ButtonProps<T extends ElementType> = { as?: T } & ComponentPropsWithoutRef<T>

function Button<T extends ElementType = 'button'>({ as, ...props }: ButtonProps<T>) {
  const Tag = as ?? 'button'
  return <Tag className="btn" {...props} />
}

function Link({ to, ...props }: { to: string } & ComponentPropsWithoutRef<'a'>) {
  return (
    <a
      href={to}
      {...props}
      onClick={e => {
        props.onClick?.(e)
        e.preventDefault()
      }}
    />
  )
}

export default function App() {
  return (
    <div>
      <Button onClick={() => console.log('Saved')}>Save</Button>
      <Button as="a" href="/pricing" onClick={e => e.preventDefault()}>See prices</Button>
      <Button as={Link} to="/about">About us</Button>
    </div>
  )
}
Saved

Run it. React draws three tags:

<button class="btn">Save</button>
<a class="btn" href="/pricing">See prices</a>
<a href="/about" class="btn">About us</a>

A component that can draw different tags like this is called polymorphic, which means “many shapes”.

Here is what is new in the code:

  • ElementType is React’s type for “anything you can put in a tag’s name”. That can be a string like 'a', or a component like Link.
  • <T extends ElementType = 'button'> makes Button generic. T is a type that TypeScript fills in each time you use Button. It reads it from the as prop. With no as, it is 'button'. Part 41 used a type like this, called P.
  • ComponentPropsWithoutRef<T> means “every prop the tag T can take, except ref“.
  • const Tag = as ?? 'button' picks the tag. The name starts with a capital letter. So JSX treats it as a variable, not as an HTML tag. Part 1 explained why.

Link is a small stand-in for Part 37’s Link. It passes every other prop on to its <a>, so the className from Button reaches the page. Its own onClick calls the caller’s onClick first, then stops the link, so the result box stays on this page.

What TypeScript checks

The types do real work. A <button> has no href, so TypeScript stops this:

import type { ComponentPropsWithoutRef, ElementType } from 'react'

type ButtonProps<T extends ElementType> = { as?: T } & ComponentPropsWithoutRef<T>

function Button<T extends ElementType = 'button'>({ as, ...props }: ButtonProps<T>) {
  const Tag = as ?? 'button'
  return <Tag className="btn" {...props} />
}

export const wrong = <Button href="/pricing">See prices</Button>

TypeScript’s error is long. Its first line says the props don’t fit a long type. Its second line says Property 'href' does not exist on type. With as="a", the same check goes the other way. <Button as="a" disabled> is an error too, because a link has no disabled.

Where the as prop gets hard

That type leaves out ref. What if we want refs too? The obvious change is ComponentProps<T>, which includes ref. We tried it with TypeScript 7.0.2, and something strange happened.

With no as, the checks still worked. But as soon as we wrote as="a", TypeScript stopped checking the other props. <Button as="a" disabled> was accepted. So was a made-up prop, bogus={1}. There was no error and no warning. The types looked safe, but they weren’t checking anything.

Why? We asked TypeScript what it picked for T. With as="a" and a wrong prop, it picked ElementType, the type that allows every tag, not 'a'. Then any prop fits. If you write the type yourself, as in <Button<'a'> as="a" bogus={1}>, the error comes back.

That is the main cost of the as prop. The idea is simple, but the types are hard, and a small change made them quietly stop working.

The asChild pattern

Radix UI does it the other way. Instead of telling Button which tag to draw, you draw the tag yourself, and put it inside:

import type { ReactNode } from 'react'

declare function Button(props: { asChild?: boolean; children: ReactNode }): ReactNode

export const prices = (
  <Button asChild>
    <a href="/pricing">See prices</a>
  </Button>
)

declare function tells TypeScript that Button exists, without writing it yet. We build it below.

With asChild, Button doesn’t draw a <button>. It takes its own props, like its className, its style and its onClick, and puts them on its only child. The page gets one <a>, with the button’s look.

To merge means to join two things into one. To clone means to make a copy. Radix UI’s docs describe it like this. With asChild set, Radix “will not render a default DOM element”. It clones the part’s child instead, and gives it “the props and behavior required to make it functional”. The piece that does this is a component Radix calls Slot. Its docs describe it in one line: “Merges its props onto its immediate child.”

Here is what happens, step by step.

1. You write a Button with asChild, and an <a> inside it. 2. Button draws no <button>. It hands its props to Slot. 3. Slot takes its only child, the <a>, with its own props. 4. Slot merges them: classes joined, both click handlers kept. 5. cloneElement makes one new <a> element with those props. 6. React puts one <a> on the page. It has the button's look. <Button asChild onClick={track}> <a href="/pricing" className="big" onClick={go}> See prices from Button from the child <a> className: "btn" style: { … } onClick: track href: "/pricing" className: "big" onClick: go Slot className: "btn big" style: Button's look onClick: go, then track href: "/pricing" cloneElement(child, mergedProps) See pricesthe page has one <a class="btn big" href="/pricing">, no <button>

How asChild puts Button’s props on its child. Press play, or step through it with the arrows.

  1. You write <Button asChild onClick={track}> with <a href="/pricing" className="big" onClick={go}> inside it.
  2. Button doesn’t draw a <button>. It hands its props to Slot: className: "btn", a style and onClick.
  3. Slot takes its only child: the <a> element, with its own props.
  4. Slot merges the two sets of props. The classes are joined into "btn big". Both click handlers will run.
  5. cloneElement makes a new <a> element with the merged props.
  6. React puts one <a> on the page. It has the button’s look and the link’s address.

To build Slot, we need three tools from React.

Three tools: Children.only, isValidElement and cloneElement

Children.only(children) gives back the one element inside a component. If there is not exactly one element, it throws an error. React’s docs say it checks that children is “a single React element”. Part 44 covers the rest of the Children tools.

isValidElement(value) says whether a value is a React element, the object that JSX makes (Part 2). For <a /> it says true. For text, a number or null, it says false.

cloneElement(element, props) makes a new element from an old one, with some props changed:

import { cloneElement } from 'react'

export default function App() {
  const link = <a href="/pricing" onClick={e => e.preventDefault()}>See prices</a>
  const styled = cloneElement(link, { className: 'btn' })

  return (
    <div>
      {link}
      {styled}
    </div>
  )
}

Run it. Two links appear. Only the second one has class="btn". The first one didn’t change. React’s docs say the original element is not changed. That fits Part 1: you must never change an element after it is made.

The new props win over the old ones. The docs say the new element will “prefer” the value from the props you pass.

An honest word about cloneElement

React’s docs list Children, cloneElement and isValidElement as legacy APIs. forwardRef is on the same list. Legacy means older tools kept for old code. The list says they “are not recommended for use in newly written code”. Legacy is not the same as deprecated (Part 14). They still work, and in our tests they printed no warning. The cloneElement page starts with a warning: “Using cloneElement is uncommon and can lead to fragile code.” Fragile means it breaks easily.

Why does it break? The props that reach the child don’t appear anywhere in the JSX you wrote. You see <a href="/pricing">, but the page gets a class, a style and a click handler too. The docs say cloneElement “makes it harder to trace the data flow”. They suggest a render prop, context or a custom hook instead.

So asChild is a trade. Radix UI uses it, so you should know how it works and where it breaks. But it is not the only answer, and the end of this part compares it with the others.

The rules for merging props

Slot gets props from two places: from Button, and from the child. Most of the time it can let one side win. A few props need both sides. These are the rules our Slot uses. We read Radix UI’s Slot code (version 1.4.0) and ran it next to ours. The rules match, with one difference, at the end of the table.

Prop Rule Example result
className join both, Button’s first "btn big"
style merge both; for the same key, the child wins Button’s padding, the child’s background
onClick, onKeyDown, and other on... props run both, the child’s first “link: onClick”, then “Button: onClick”
ref give the tag to both refs both refs hold the <a>
anything else the child wins the child’s href, id or title
a child value that is undefined or null it doesn’t count; Button’s value stays className={undefined} keeps "btn"

Why does the child win? The child is the tag you wrote yourself, right where you use it. If you gave it a value, you meant that value.

The last row matters. A child often passes a prop on that happens to be empty, like className={props.className} with no class given. That must not wipe out Button’s class. An earlier version of our Slot got this wrong. With className={null} it made class="btn null", and Button’s onClick never ran. Now undefined and null are skipped, and empty classes are dropped by filter(Boolean).

Radix UI does the same for className, style and the on... handlers. For any other prop, Radix lets the child’s undefined or null win. We tested it: with id={undefined} on the child, Radix’s <a> had no id, and ours kept Button’s. Radix also joins one more prop, aria-describedby.

Which click handler runs first?

When both sides have an onClick, our Slot makes one new function that calls both. We call the child’s first, then Button’s. Radix UI does the same. Its Slot docs say “the child handler takes precedence over the slot handler”. Takes precedence means “goes first”. Its code shows it: the new function calls childPropValue(...args), then slotPropValue(...args).

Why does the order matter? The first handler can call e.preventDefault(), and the second can check e.defaultPrevented. That is a true or false value that says whether someone already called preventDefault(). So the child can say “stop”, and Button’s handler can listen. Radix’s docs show this. But Radix’s Slot still calls the slot’s handler after the child’s. It is the slot handler’s job to check e.defaultPrevented. Radix’s own parts do that check with a helper called composeEventHandlers, from @radix-ui/primitive. It calls your handler first, then skips the part’s own handler if e.defaultPrevented is true.

Refs

Part 14 showed that in React 19, ref is a normal prop. So the child’s own ref is in child.props.ref.

Don’t read child.ref. In React 19.3, we tried it, and React printed this warning in development:

Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release.

There is a problem. If Slot passes a new ref to cloneElement, it replaces the child’s ref. React’s cloneElement page says that a ref you pass will “replace the original ones”. We checked. With only Button’s ref passed on, the child’s own ref stayed null.

So Slot passes one ref callback (Part 14) that fills both refs.

Build a small Slot

Here is the whole thing: a Slot, a Button that uses it, and an app to test it.

import { Children, cloneElement, isValidElement, useRef, useState, type ComponentProps, type CSSProperties, type ReactNode, type Ref } from 'react'

type AnyProps = Record<string, unknown>

function setRef<T>(ref: Ref<T> | undefined, node: T | null) {
  if (typeof ref === 'function') return ref(node)
  if (ref) ref.current = node
}

function composeRefs<T>(...refs: (Ref<T> | undefined)[]) {
  return (node: T | null) => {
    const cleanups = refs.map(ref => setRef(ref, node))
    return () => {
      cleanups.forEach((cleanup, i) => {
        if (typeof cleanup === 'function') cleanup()
        else setRef(refs[i], null)
      })
    }
  }
}

function mergeProps(slotProps: AnyProps, childProps: AnyProps) {
  const merged: AnyProps = { ...slotProps }
  for (const name in childProps) {
    const slotValue = slotProps[name]
    const childValue = childProps[name]
    if (childValue === undefined || childValue === null) continue
    if (name === 'className') {
      merged.className = [slotValue, childValue].filter(Boolean).join(' ')
    } else if (name === 'style') {
      merged.style = { ...(slotValue as CSSProperties), ...(childValue as CSSProperties) }
    } else if (/^on[A-Z]/.test(name) && typeof slotValue === 'function' && typeof childValue === 'function') {
      merged[name] = (...args: unknown[]) => {
        childValue(...args)
        slotValue(...args)
      }
    } else {
      merged[name] = childValue
    }
  }
  return merged
}

type SlotProps = AnyProps & { children?: ReactNode; ref?: Ref<HTMLElement> }

function Slot({ children, ref, ...slotProps }: SlotProps) {
  const child = Children.only(children)
  if (!isValidElement<SlotProps>(child)) {
    throw new Error('Slot needs one element inside it.')
  }
  return cloneElement(child, {
    ...mergeProps(slotProps, child.props),
    ref: composeRefs(ref, child.props.ref),
  })
}

type ButtonProps = ComponentProps<'button'> & { asChild?: boolean }

const look: CSSProperties = { padding: '6px 12px', borderRadius: 6, background: 'teal', color: 'white' }

function Button({ asChild = false, ...props }: ButtonProps) {
  if (asChild) {
    return <Slot className="btn" style={look} {...props} />
  }
  return <button className="btn" style={look} {...props} />
}

export default function App() {
  const buttonRef = useRef<HTMLButtonElement>(null)
  const linkRef = useRef<HTMLAnchorElement>(null)
  const [found, setFound] = useState('not checked yet')

  return (
    <div>
      <Button asChild ref={buttonRef} onClick={() => console.log('Button: onClick')}>
        <a
          href="/pricing"
          ref={linkRef}
          className="big"
          style={{ background: 'navy' }}
          onClick={e => {
            e.preventDefault()
            console.log('link: onClick')
          }}
        >
          See prices
        </a>
      </Button>
      <p>
        <button onClick={() => linkRef.current?.click()}>Click the link for me</button>
        <button onClick={() => setFound(buttonRef.current?.tagName + ' and ' + linkRef.current?.tagName)}>Check the refs</button>
      </p>
      <p>The refs hold: {found}</p>
    </div>
  )
}
link: onClick
Button: onClick

Run it. “See prices” shows as one navy link with the button’s padding and white text. React put this on the page:

<a href="/pricing" class="btn big" style="padding: 6px 12px; border-radius: 6px; background: navy; color: white;">See prices</a>

Press “Click the link for me”. It calls click() on the real <a>, through linkRef. The Console shows “link: onClick”, then “Button: onClick”. The child’s handler ran first. A click is not a render, so Strict Mode doesn’t double these lines.

Press “Check the refs”. It says “A and A”. Both refs hold the same <a>.

Now the code, piece by piece.

setRef fills one ref. A ref can be a function (a ref callback) or an object with current. It may also be missing. If the ref callback returns a cleanup function, setRef gives it back.

composeRefs makes one ref callback that fills both refs. It also returns a cleanup, as Part 14 showed. The cleanup runs each ref’s own cleanup. A ref with no cleanup is set back to null instead. This is how Radix UI’s composeRefs works too.

mergeProps starts with Button’s props. Then it goes through the child’s props. A child value that is undefined or null is skipped. Otherwise it joins className, merges style, and wraps two on... handlers in one function. Any other child value wins. /^on[A-Z]/ is a test that the name starts with “on” and then a capital letter, like onClick. Radix UI uses the same test.

Slot takes children and ref out of its props. Everything left is what Button wants on the child. Children.only throws if there isn’t exactly one element. It already throws for text too, so the isValidElement check never fails at run time. It is there for TypeScript. Children.only gives back a value of type ReactNode. TypeScript needs to know it is an element before we read child.props.

Button draws a <button> as before. With asChild, it gives the same props to Slot instead. asChild itself is taken out first, so it never reaches the page.

The Slot is about 50 lines. Radix UI’s does more. It can find the child inside a part named Slottable, and it handles a few more cases.

One thing is not right. TypeScript thinks buttonRef holds a <button>, because Button‘s props come from <button>. With asChild, it holds an <a>. TypeScript can’t know which child you will pass. So with asChild, the type of a ref to Button can be wrong.

An everyday example

Think of a new school bag that comes with a sheet of stickers. Normally the stickers go on that bag. With asChild, you say: “I have my own bag. Put the stickers on mine.” You get one bag, your own, with the stickers on it.

The exact version

Slot doesn’t change your child. It makes a new element from it, with merged props, and React draws that one.

If the child is an HTML tag, like <a>, React puts the props on the page. If the child is your own component, the props arrive as that component’s props. Nothing reaches the page unless that component passes them on. The next section shows what happens when it doesn’t.

Also, composeRefs makes a new function each time Slot renders. Part 14 showed what that costs: React runs the old callback’s cleanup, then calls the new one. We measured it with Strict Mode off. The child’s own ref was a Part 14 style callback that stayed the same between renders, and returned a cleanup. Each time App rendered again, its cleanup ran once, then it got the <a> again. It never got null.

The cleanup matters. Our first version set both refs and returned nothing. Then React called the child’s callback with null on every render, and a callback that expected a tag threw Cannot read properties of null (reading 'tagName').

Radix UI keeps its callback the same with useCallback (Part 21). That works only when both refs stay the same between renders. In our test they did, and Radix’s Slot set the child’s ref once and never again.

When asChild breaks

Two children, or text

Children.only needs exactly one element. This little component shows what happens when it gets two:

import { Children, type ReactNode } from 'react'

function OnlyOne({ children }: { children: ReactNode }) {
  return Children.only(children)
}

export default function App() {
  return (
    <OnlyOne>
      <a href="/one">One</a>
      <a href="/two">Two</a>
    </OnlyOne>
  )
}

Run it. React stops with this error:

React.Children.only expected to receive a single React element child.

We got the same error for text, like <OnlyOne>See prices</OnlyOne>, and for nothing at all, <OnlyOne />. TypeScript doesn’t catch two children or text, because children is a ReactNode, and text is a ReactNode too. It catches only <OnlyOne />, with Property 'children' is missing, because the type says children is required. Radix UI’s Slot checks the child itself and gives its own message. We ran it with two children and with text. Both times it said:

Slot failed to slot onto its children. Expected a single React element child or `Slottable`.

There is one more difference. When the child is null, false or undefined, Radix’s Slot draws nothing. Ours throws the Children.only error. That matters for code like {isOpen && <a href="/help">Help</a>}. When isOpen is false, the child is false. We tried it: Radix drew nothing, and ours threw.

A child component that doesn’t pass props on

The child can be your own component, like a router’s Link. Part 37’s Link takes only to and children. See what happens when we clone it with a class, the way Slot does:

import { cloneElement, type ReactNode } from 'react'

function Link({ to, children }: { to: string; children: ReactNode }) {
  return <a href={to} onClick={e => e.preventDefault()}>{children}</a>
}

export default function App() {
  return cloneElement(<Link to="/about">About us</Link>, { className: 'btn' })
}

Run it. The page gets <a href="/about">About us</a>. The class is gone. Link got className as a prop and never used it. React printed no warning, and TypeScript said nothing. With <Button asChild> around this Link, the look, the style and Button’s onClick would all be lost. We tried that with our Slot. The page got a plain <a>, and a click on it ran Button’s handler 0 times.

The fix is to pass every other prop on, and call the caller’s onClick as well as your own:

import type { ComponentProps, MouseEvent } from 'react'

declare function navigate(to: string): void

function Link({ to, onClick, ...props }: { to: string } & ComponentProps<'a'>) {
  function handleClick(e: MouseEvent<HTMLAnchorElement>) {
    onClick?.(e)
    if (e.defaultPrevented) return
    e.preventDefault()
    navigate(to)
  }
  return <a href={to} {...props} onClick={handleClick} />
}

Part 37’s checks for Ctrl and the other keys are left out, to keep it short. We tried this Link inside our <Button asChild>. The class and the style reached the <a>. A click ran Button’s handler, then navigate.

...props also carries ref, because in React 19 it is a normal prop. Radix UI’s docs give the same rule, in two parts: “Your component must spread props”, and “Your component must forward ref”. The second one comes from before React 19, when a component needed forwardRef to get a ref. Part 14 explained forwardRef.

What if a component drops the ref? We tried a child component that takes ref and never uses it. Both refs stayed null, and React printed no warning.

The child decides how it behaves

With asChild, the tag on the page is the child. So the child, not Button, decides how it behaves.

An <a href> child acts like a link. We pressed keys on a focused <a href> in Chromium, Firefox and WebKit, on Linux, with default settings. Enter fired a click and opened the link. Space fired no click at all. It scrolled the page down instead. So Button’s onClick runs on a click or on Enter, not on Space. That is right for a link. It matches the keys the W3C guide lists for links.

A <button> child acts like a button. Enter and Space both fired a click, in all three browsers.

A <div> or <span> child acts like nothing. We tried both, with a click handler on each. Tab skipped them. Calling focus() on them did nothing, so the keys never reached them. A mouse could still click, but a keyboard user couldn’t use them at all.

Radix UI’s docs warn about this. If you change the tag, “it is your responsibility to ensure it remains accessible and functional”. About a trigger they say: “If you were to switch it to a div, it would no longer be accessible.”

So pick the child for the job: <a href> to go somewhere, <button> to do something. Part 46 and Part 47 cover what it takes to make other tags work with a keyboard.

as, asChild, or two components?

You now have three ways to share a button’s look with a link. None of them wins every time.

Two components. Write Button, which draws a <button>, and ButtonLink, which draws an <a>. Both use the same style or class from one place. The types are plain, and each name says what it draws. This is often good enough when you only need those two tags.

import type { ComponentProps, CSSProperties } from 'react'

const look: CSSProperties = { padding: '6px 12px', borderRadius: 6, background: 'teal', color: 'white' }

export function Button(props: ComponentProps<'button'>) {
  return <button className="btn" style={look} {...props} />
}

export function ButtonLink(props: ComponentProps<'a'>) {
  return <a className="btn" style={look} {...props} />
}

The as prop. One component, one prop, and the JSX is short: <Button as="a" href="/pricing">. Choose it when you switch between a few tags. Be ready for hard types, and test that they still catch mistakes.

asChild. The caller draws any tag or component, and Button adds its look and behaviour. It works with a router’s Link and with parts from other libraries. Button‘s own types stay plain. The costs: it needs cloneElement, the child must pass props and refs on, and it takes exactly one child. Choose it for shared parts that must work with many kinds of child, the way Radix UI’s parts do.

There is also the render prop from Part 40. Button could call your function with the props to add, and you spread them onto your own tag: render={props => <a href="/pricing" {...props} />}. You can see every prop that arrives. That is the reason React’s docs give for render props: you “can clearly trace” where a value comes from. But you must merge the class and the handlers yourself.

Common mistakes

export const wrong = (
  <button className="btn">
    <a href="/pricing">See prices</a>
  </button>
)

What goes wrong: two Tab stops, two click handlers for one click, and keys that differ by browser. React and the browser accept it without a word. The fix: one tag. Use <Button asChild> with an <a> inside, or a ButtonLink.

Handlers that replace each other

A simple Slot might spread the props and stop there, like this one. The kata’s Slot does the same for handlers and classes. It does merge style.

import { cloneElement, isValidElement, type ReactNode } from 'react'

function Slot({ children, ...slotProps }: { children: ReactNode; onClick?: () => void }) {
  if (!isValidElement<Record<string, unknown>>(children)) return null
  return cloneElement(children, { ...slotProps, ...children.props })
}

export default function App() {
  return (
    <Slot onClick={() => console.log('Button: onClick')}>
      <button onClick={() => console.log('child: onClick')}>Save</button>
    </Slot>
  )
}
child: onClick

Run it and press “Save”. Only “child: onClick” appears. The child’s onClick replaced Button’s, because the last spread wins. A className would be lost the same way. The fix is the mergeProps above: call both handlers, and join the classes.

More than one child, or text

<Button asChild>See prices</Button> and two tags inside <Button asChild> both throw React.Children.only expected to receive a single React element child. The fix: put exactly one tag inside.

A child component that drops props or the ref

A component like Part 37’s Link, which takes only to and children, loses the class, the style and the handlers. A component that ignores ref leaves the ref null. Neither prints a warning. The fix: spread the other props onto the tag, call the caller’s onClick, and pass ref on.

A <div> as the child

<Button asChild><div>Save</div></Button> looks like a button but can’t be reached with Tab, and Enter and Space do nothing. The fix: use a <button> or an <a href> as the child.

<Button asChild disabled> with an <a> inside type-checks, because Button‘s props come from <button>. React puts disabled="" on the <a>. But a link has no disabled. We tried it in all three browsers. Tab still stopped on the link. A click and Enter still opened it, and its click handler still ran. The fix: don’t pass disabled with a link child. If a link must be turned off, draw something else. Use a <span>, or a real <button disabled>.

Reading child.ref

In React 19, child.ref prints a warning in development that starts with Accessing element.ref was removed in React 19. Read child.props.ref instead.

Practice

Use the “Build a small Slot” example. Press Edit there for each task.

  1. In mergeProps, change the order of the two lines inside the new handler, so slotValue(...args) comes first. Run it and press “Click the link for me”. What does the Console show?
  2. Add console.log('Slot renders') as the first line of Slot. Run it. How many lines appear? Then press “Check the refs”. How many more?
  3. Add a second link, <a href="/help">Help</a>, inside <Button asChild>, under the first one. What happens when you run it?
  4. Change look so it has background: 'teal' and fontWeight: 'bold'. The child still sets background: 'navy'. Which background does “See prices” get, and is its text bold?
Answers
  1. “Button: onClick”, then “link: onClick”. Button’s handler now runs first. Each line appears once, because Strict Mode doesn’t repeat event handlers.
  2. Two lines when the page loads, because Strict Mode renders each component twice in development. “Check the refs” changes state in App, so App, Button and Slot render again: two more lines, four in all.
  3. React stops with React.Children.only expected to receive a single React element child.
  4. The background is navy, because for the same style key the child wins. The text is bold. Only look sets fontWeight, so it is kept.

Interview questions

Try to answer each one out loud before you open the answer.

What problem does asChild solve?

A shared component, like Button, owns a look and some behaviour. Sometimes the caller needs a different tag, like an <a> or a router’s Link. With asChild, the component doesn’t draw its own tag. It merges its props onto its one child. The page gets one tag: the child’s tag, with the button’s look. That avoids nesting that breaks the HTML rules, like <button><a>. It also avoids copying the look.

A strong answer names the alternatives: the as prop and two separate components. It also names the costs: cloneElement, exactly one child, and a child that must pass props and refs on.

Why is a link inside a button a problem, if it looks fine?

HTML doesn’t allow interactive content inside a <button> or an <a>. The browser keeps the nesting anyway, and React prints no warning for it. In our tests in Chromium, Firefox and WebKit, it gave two Tab stops. One click ran both click handlers and opened the link. Space on the inner link clicked the button in Chromium and WebKit, and did nothing in Firefox. The axe-core tool reports nested-interactive for a link inside a button. A strong answer adds that axe didn’t report a button inside a link, so a clean report proves nothing here.

How does a Slot merge its props with the child’s?

Most props: the child wins, because the caller wrote the child’s props on purpose. But a child value that is undefined or null doesn’t count, so Button’s value stays. className: both are joined, and empty ones are dropped. style: both objects are merged, and for the same key the child wins. Event handlers: both run. ref: one callback fills both refs. A strong answer adds that cloneElement alone would replace the child’s ref, so the refs must be composed.

When both have an onClick, which runs first, and why does it matter?

In Radix UI’s Slot, and in ours, the child’s handler runs first, then the slot’s. Radix’s docs say “the child handler takes precedence over the slot handler”. The order matters for e.preventDefault(). The child can call it, and the slot’s handler can check e.defaultPrevented and skip its work. Radix’s Slot still calls the slot’s handler, so the check is the slot handler’s job. Radix’s own parts use composeEventHandlers, which skips the part’s handler when e.defaultPrevented is true.

Why is the as prop hard to type?

The props depend on the tag. With as="a", href is allowed and disabled is not. So the component needs a generic type, filled in from as. That works for props with ComponentPropsWithoutRef<T>. Adding ref is where it gets hard. We tried ComponentProps<T> with TypeScript 7.0.2. With as="a", it accepted any prop at all, even a made-up one. TypeScript picked ElementType for T, not 'a'. Writing <Button<'a'> ...> brought the errors back. A strong answer says to test your types with a wrong prop.

What must a component do to work as an asChild child?

It must pass the props it doesn’t use on to its tag, usually with {...props}. It must call the caller’s handlers, not replace them. And it must pass ref on. In React 19 that is a normal prop, so the spread does it. Before React 19, it needed forwardRef. If it drops any of these, they are lost with no warning.

React’s docs call cloneElement legacy. Why do libraries still use it?

The docs say it is “uncommon and can lead to fragile code”. The props that arrive can’t be seen in the JSX. They suggest render props, context or custom hooks instead. asChild uses it because the caller writes the child tag. The library must add props to a tag it didn’t create. A render prop can do the same job, and you can see every prop that arrives. But the caller then has to merge them. A strong answer compares both and doesn’t call either one always right.

Sources

  • cloneElement, react.dev: “Using cloneElement is uncommon and can lead to fragile code”, that the new props are preferred, that a passed ref replaces the old one, that the original is not changed, and the alternatives.
  • Legacy React APIs, react.dev: Children and cloneElement “are not recommended for use in newly written code”.
  • Children and isValidElement, react.dev: what Children.only and isValidElement do.
  • React 19, React blog: ref as a prop for function components.
  • Radix UI: Composition (“will not render a default DOM element”, “Your component must spread props”, “Your component must forward ref”, and the warning about a div) and Slot (“Merges its props onto its immediate child”, and the handler order). The @radix-ui/react-slot 1.4.0 and @radix-ui/react-compose-refs 1.1.5 code on npm: the merge rules, the order of handlers, the ref callback in useCallback, and the error text.
  • MDN: <button> and <a>: what each may hold, and Enter on a link.
  • W3C ARIA Authoring Practices Guide: Button Pattern (Space and Enter) and Link Pattern (Enter).
  • nested-interactive, Deque: the rule’s page. It describes axe-core 4.10; our run used 4.14.0.
  • Developing a Keyboard Interface, W3C ARIA Authoring Practices Guide: on a Mac, Tab moves only between form parts unless a system setting is on.
  • @radix-ui/primitive 1.1.7 code on npm: composeEventHandlers skips the part’s handler when defaultPrevented is true.
  • The HTML, the logs, the warnings, the error text, the ref results and the TypeScript results come from running React 19.3.0 and TypeScript 7.0.2 for this post. The parser, Tab, click, key and axe-core 4.14.0 results come from Chromium 151, Firefox 153 and WebKit 26.5, run without a screen on Linux with default settings. The axe quotes come from that run. Why axe flags only one shape comes from axe-core 4.14.0’s code: nested-interactive only checks roles marked childrenPresentational, and button is one, link is not.
  • This part follows the asChild Pattern kata in react-katas. The kata’s Slot spreads the child’s props last, so the child’s onClick replaces Button’s instead of running with it, and the classes are not joined. This part merges both. The kata’s Slot also wraps text in a <span>. Ours throws, as Radix UI’s does.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.