open atlas
↑ Back to track
React, zero to senior RCT · 12 · 03

Polymorphic components: the as prop versus asChild and Slot

Polymorphism two ways: the as prop swaps the rendered element and pays in polymorphic ref types and type-instantiation blowup; asChild merges props onto the existing child via Slot — className concat, handler chaining, ref composition. as suits primitives; asChild suits behavior.

RCT Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

The design-system team shipped a fully typed polymorphic as prop on Box, Text, and Button — the proud kind, with ComponentPropsWithoutRef inference and a polymorphic ref. The PR was beautiful. Three weeks later the platform channel filled with complaints: editor autocomplete on Box took seconds, a one-character typo in an href produced a 60-line error wall naming types nobody wrote, and the monorepo’s tsc time had grown by half a minute — Box had 4,000 call sites, and every one now instantiated a conditional type over the entire JSX element map. Meanwhile a product team hit the runtime version of the same problem from the other side: they needed Button styling on a router Link, wrote <Button as={Link}>, and discovered Button’s disabled logic silently meant nothing on an anchor. The eventual fix was two tools instead of one: as stayed — but only on the style primitives, where its own props are few and the inference is shallow; behavior components dropped it for asChild, where Button renders no element at all and instead merges its props onto the child you give it. Same goal — one component, many elements — two mechanisms with opposite cost profiles.

One behavior, many elements

The need is universal in design systems: Button must sometimes be a button, sometimes an a, sometimes a router Link — same padding, same focus ring, same press states. The naive answers are all wrong. Nesting (a around button) is invalid HTML with broken semantics — interactive inside interactive confuses every assistive technology. Copying the styles onto a second component drifts within a quarter. Slapping onClick plus styles on a div loses keyboard activation, focus, and the accessibility tree entirely. So the real options are two: change what the component renders (the as prop) or adopt the element the caller already wrote (asChild).

The as prop, and the bill TypeScript sends

At runtime, as is almost nothing:

function Box({ as, ...props }) {
  const Comp = as ?? "div";
  return <Comp {...props} />;
}
// <Box as="a" href="/docs">Docs</Box>  →  renders <a href="/docs">

The entire cost lives in the types. A correct polymorphic component must say: “my props are my own props, plus the props of whatever element as names, minus collisions — and my ref is typed for that element.” That last clause is the notorious one: the ref type must be derived per call site (ComponentPropsWithRef<E>["ref"]), and the props type is a conditional/mapped type over E extends ElementType. It works, and every utility library that offers it carries pages of caveats — because the instantiation cost scales with call sites. Each <Box as={...}> makes the compiler resolve the conditional type against the full element map; at thousands of call sites that is measurable tsc time and visible editor latency, and error messages stop naming your code and start naming type machinery. There is also a semantic trap the types cannot catch: your component’s behavioral props may be meaningless on the new element — disabled on an anchor does nothing natively, so the component must translate it (aria-disabled, blocked handlers) or honestly not support it. The honest verdict: as is the right tool for style-system primitives — Box, Text, Stack — where own-props are few, behavior is nil, and the polymorphism is purely about which tag carries the styles.

Quiz

After Box gains a fully typed polymorphic as prop and is used at ~4,000 call sites, tsc time and editor latency jump noticeably. What actually grew?

asChild and Slot: composition instead of type gymnastics

Radix popularized the alternative. With asChild, the component renders no element of its own — it renders a Slot, and Slot clones its single child, merging the component’s props into it:

<Button asChild>
  <Link to="/billing">Upgrade</Link>
</Button>
// Renders ONE element: the Link's <a>, now carrying Button's
// className, data attributes, event handlers, and ref.

Inside, Button does const Comp = asChild ? Slot : "button". Slot takes the child element and produces a clone with merged props — the mechanics matter because you will debug them:

  • className: concatenated — both the slot’s and the child’s classes survive.
  • style: object-merged, the child’s properties winning on collision.
  • event handlers: chained, child first — the child’s onClick runs, then the slot’s. Well-built primitives compose with a defaultPrevented check, so a child that calls event.preventDefault() can stop the primitive’s own logic; a naive merge runs both unconditionally, which is how double-firing bugs ship.
  • ref: composed — a callback ref that fills both the parent’s and the child’s. React 19 made this materially simpler: ref is now a regular prop on function components, so merging no longer needs forwardRef wrappers or reaching into element.ref (deprecated access that warned through React 18).

The contract this buys: no type gymnastics — Button keeps its own simple props, and the child keeps its own; TypeScript checks each side separately. The price is two new failure modes. Multiple children throw: Slot must clone exactly one element (React.Children.only), so <Button asChild><Icon /><span>Save</span></Button> is a runtime error — wrap them in one element. And the child must cooperate: Slot merges props into the child element, but if the child is a custom component that ignores unknown props — renders its own span without spreading — every merged handler, ARIA attribute, and ref silently vanishes. The rendered widget looks right and does nothing; the debugging move is inspecting the DOM for the expected data-state/aria-* attributes.

Quiz

Button asChild wraps a router Link. Both attach onClick, and the child Link handler calls event.preventDefault() to do client-side navigation. What runs, and in what order?

Choosing: as for primitives, asChild for behavior

The two tools partition cleanly along what the component is:

  • as for style-system primitives. Box, Text, Stack, Grid: no behavior, few own props, shallow inference. The polymorphism is “which tag carries these styles” — as="section", as="label". Keep the accepted union narrow (intrinsic elements, maybe a couple of components) and the TypeScript bill stays small.
  • asChild for behavior wrappers. Button, Tooltip.Trigger, Dialog.Trigger, DropdownMenu.Trigger: rich behavior (handlers, ARIA, focus), and the caller’s element is often itself a component (router Link) with its own props. asChild sidesteps the type explosion entirely and composes behaviorsTooltip.Trigger asChild around Dialog.Trigger asChild around your Button stacks three behavior sets onto one rendered element, something as cannot express at all.

The remaining discipline is documentation: an asChild API must state its child contract loudly — one element child; the child must spread unknown props and accept a ref. Design systems that skip this paragraph generate a steady stream of “tooltip silently does not open” tickets from teams whose child component swallowed the merge.

Why this works

Why did Slot’s prop-merging rules land where they did? Each rule answers a concrete conflict. Child-first handler order exists because the child is the application’s code and the slot is infrastructure: the app must get the event first and retain the power to veto (via preventDefault, when the primitive composes with a defaultPrevented check) the infrastructure’s reaction. className concatenation exists because classes are additive by design — dropping either side’s classes breaks either the primitive’s state styling or the app’s brand. Style object merge with child-wins exists because the more specific intent should override the generic one. And ref composition exists because both sides genuinely need the node — the primitive for focus and measurement, the app for whatever it measures. React 19 folding ref into ordinary props removed the last piece of magic: merging refs is now just merging another prop, no forwardRef ceremony, no deprecated element.ref access.

Recall before you leave
  1. 01
    Contrast where the as prop and asChild each pay their costs, and state the resulting placement rule for a design system.
  2. 02
    Walk through Slot's prop-merging rules and the failure modes a reviewer should check for.
Recap

One component rendering as many elements is a structural need in any design system, and the two mechanisms that answer it pay at opposite ends. The as prop swaps the rendered tag — trivially at runtime, expensively in TypeScript, where a correct polymorphic signature is a conditional type over the element map with a per-element ref, instantiated at every call site; at thousands of sites that is real tsc time, visible editor latency, and error messages naming type machinery instead of your code. Its honest home is the style primitive — Box, Text, Stack — with few own props, no behavior, and a deliberately narrow accepted union. asChild inverts the approach: the component renders a Slot, which clones the single child element and merges the prop sets — className concatenated, style merged child-wins, event handlers chained child-first (with a defaultPrevented check in well-built primitives, the absence of which produces double-firing), refs composed into one callback, a step React 19 simplified by making ref an ordinary prop. The type system stays simple because each side keeps its own props, and behaviors compose — multiple asChild triggers can stack on one element, which as cannot express. The new contracts are the cost: Slot throws on multiple children, and a child component that fails to spread unknown props or accept a ref swallows the merge silently — the widget looks right and does nothing, and the diagnostic is checking the rendered DOM for the data attributes that should have arrived. Partition accordingly: as for style primitives, asChild for behavior wrappers, and the child contract documented loudly. Now when you see a “tooltip silently does not open” ticket, your first move is inspecting the DOM: if the expected data-state and aria-* attributes are missing on the child element, the child is swallowing the merge — and you know exactly what to fix.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.