DEV Community

Cover image for asChild: Understanding the Slot Pattern in React
Andrés Valdivia Cuzcano
Andrés Valdivia Cuzcano

Posted on

asChild: Understanding the Slot Pattern in React

What is a Slot?

Have you ever struggled to create a truly flexible UI component? Imagine a Button that needs to render as a native <button>, an <a> tag for external links, or a Next.js <Link> for navigation—all while maintaining the exact same styles, hover states, and logic from your design system.

You might have tried these common but limited approaches:

  1. Conditional Rendering: Using if/else inside your component to switch tags based on a prop (e.g., as="a"). This often leads to "prop bloating" and complex types.
  2. Utility Classes: Exporting CSS classes for others to use. This keeps styles consistent but loses the encapsulated behavior of your component.

The Slot pattern solves this through true composition. Your component provides the "skin" and behavior, while the consumer provides the underlying element. The result? No DOM pollution, no prop drilling, and clean, reusable code.

Building the Foundation

To create a Slot that feels "magic" to the end user, we first need to build a robust engine under the hood. The challenge of the Slot pattern is that we are essentially merging two different components into one DOM node. This means we have to manually handle things that React usually does for us automatically: managing references and merging properties.

💡 A Note on React version

This implementation is designed for React 19 and beyond. Starting with React 19, ref is treated as a standard prop, meaning we no longer need to wrap our components in forwardRef to access them.
If you are using React 18 or lower, the logic for passing and receiving refs will differ. You would need to use React.forwardRef to capture the ref from the consumer and pass it into our composeRefs utility.

Combining Refs

A core requirement of the Slot pattern is that the final DOM node must be accessible to both the design system and the consumer. However, a React element can only accept a single ref prop.

The "problem" is that we need to "combine" the Slot's internal references and the child's potential references into one. To do this, we use a callback ref approach. This allows us to receive the DOM node and "broadcast" it to every reference that needs it.

function composeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
  return (node: T) => {
    refs.forEach((ref) => {
      if (typeof ref === 'function') {
        ref(node);
      } else if (ref != null) {
        ref.current = node;
      }
    });
  };
}
Enter fullscreen mode Exit fullscreen mode

How it works

The callback we return is executed as soon as the component is mounted. Within the loop, we check if the ref is a function (callback) to execute it, or an object to store the node in the .current property. This ensures that no matter how the consumer or the Slot defines their reference, the DOM node is stored correctly in all locations.

Merging Props

Now that we can synchronize references, we need to handle the rest of the props. The goal is simple: the Slot defines the "base" contract (styles and behavior), while the Child provides the specific implementation.

However, we can't just spread props blindly. We have to decide which props should be overridden and which should be merged.

  1. Prop Prioritization: Default vs. Intentionality

    In the Slot pattern, we treat the Slot as the provider of defaults and the Child as the source of truth.
    The Slot defines the base design system requirements (like aria- / data- attributes or styles), but the child element represents the specific use case. If there is a conflict, the child’s props should take precedence because they represent the developer's intentional choice for that specific implementation.

  2. The three special merge cases

    While most props follow a "last one wins" rule, three specific types of props require a more surgical approach to avoid losing functionality:

    Prop Type Merging Strategy Why?
    CSS Classes Concatenation We want to keep the Slot's layout styles and the child's custom utility classes.
    Inline Styles Object Merging To ensure styles from the Slot doesn't wipe out specific ones from the child.
    Event Handlers Chaining We need the Slot's default behavior (e.g., show an alert) and the child's specific action (e.g., onClick) to both fire.

To achieve this, we can create a mergeProps utility. Here is how we handle them:

function mergeProps(slotProps: AnyProps, childProps: AnyProps) {
  const overrideProps = { ...childProps };

  for (const propName in childProps) {
    const slotValue = slotProps[propName];
    const childValue = childProps[propName];

    // Check if the current prop is an Event Handler
    const isEventHandler = /^on[A-Z]/.test(propName);

    if (isEventHandler && slotValue && childValue) {
      overrideProps[propName] = (...args: unknown[]) => {
        childValue(...args);
        slotValue(...args);
      };
      continue;
    }

    // If both have the same CSS property, we prioritize the child's one
    if (propName === 'style') {
      overrideProps[propName] = { ...slotValue, ...childValue };
      continue;
    }

    if (propName === 'className') {
      overrideProps[propName] = [slotValue, childValue]
        .filter(Boolean)
        .join(' ');
    }
  }

  return { ...slotProps, ...overrideProps };
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip:

When chaining event handlers, it’s often best practice to call the child’s handler first. If the child calls event.preventDefault(), the Slot can then decide whether or not to proceed with its default behavior.

The Transformation: Cloning the Child

Now that we have our utility functions for merging props and combining refs, it’s time to build the component that performs the "transformation”.

The Slot is essentially a transparent wrapper. Its job isn't to render a <div> or a <span> of its own, but to "possess" its child. We take the setup defined on the Slot (styles, event handlers, and refs) and inject them directly into the child element. To the browser, the Slot doesn't exist—only the augmented child does.

How it works

To achieve this, we use React.cloneElement. This allows us to take the child component (the "slottable" element) and return a new version of it that merges the Slot’s properties with its own.

Key Benefits of this Approach:

  • Zero DOM Pollution: You won't see any extra wrapper tags in your inspector.
  • Flat Hierarchy: The child remains the top-level element, preserving CSS selectors like :first-child, flexbox layouts and so on.
  • Prop Injection: We effectively "teleport" the design system logic from the Slot directly into the underlying element (be it a native <button>, a Next.js <Link>, or a custom component).
type SlotProps = React.DetailedHTMLProps<
  React.HTMLAttributes<HTMLElement>,
  HTMLElement
>;

function getElementRef(
  element: React.ReactElement,
): React.Ref<unknown> | undefined {
  return (element.props as { ref?: React.Ref<unknown> }).ref;
}

function createSlotClone(ownerName: string) {
  const SlotClone = ({ children, ...slotProps }: SlotProps) => {
    // Verify that 'children' is a single, valid React element (not a React Node)
    if (React.isValidElement(children)) {
      const childrenRef = getElementRef(children);
      const mergedProps = mergeProps(slotProps, children.props as any);

      if (children.type !== React.Fragment) {
        mergedProps.ref = ref ? composeRefs(ref, childrenRef) : childrenRef;
      }

      return React.cloneElement(children, mergedProps);
    }

    // Fallback: If there's more than one child, we call React.Children.only.
    // This will throw a descriptive error to enforce the "single-child" contract.
    // If no children exist, we simply render nothing.
    return React.Children.count(children) > 1
      ? React.Children.only(null)
      : null;
  };

  // Attach the displayName for easier debugging in DevTools
  SlotClone.displayName = `${ownerName}.SlotClone`;
  return SlotClone;
}
Enter fullscreen mode Exit fullscreen mode

Putting it Together: The Public Slot API

Now that we have the internal logic to handle cloning, refs, and props, we need to expose a clean, usable component. We do this using a Factory Function.

This factory, createSlot, generates a Slot component specifically tailored for a specific part of your design system (like a Button or a Card). It acts as the "entry point" for the pattern, delegating the heavy lifting to the SlotClone we built earlier.

function createSlot(ownerName: string) {
  // Initialize the internal clone logic with the owner's name
  const SlotClone = createSlotClone(ownerName);

  // This is the actual component the developer will use
  const Slot = ({ children, ...slotProps }: SlotProps) => {
    return <SlotClone {...slotProps}>{children}</SlotClone>;
  };

  // Attach the displayName for easier debugging in DevTools
  Slot.displayName = `${ownerName}.Slot`;
  return Slot;
}

// Create and export the Slot component
export const Slot = createSlot('Slot');
Enter fullscreen mode Exit fullscreen mode

Why this separation matters

By separating Slot from SlotClone, we create a clean boundary:

  1. The Slot: Is the simple interface. It accepts props and children just like any other React component.
  2. The SlotClone: Is the "engine." It handles the complex work of merging styles, chaining event handlers, and synchronizing refs.

This structure makes our code highly maintainable. If we ever need to change how props are merged, we only change the internal logic, and the public Slot API remains exactly the same.

Graph showing the process of how a single-child slot works

The "Slottable" Component: Handling Complex Content

As the components grow, they often need more than just a single text string. In a Button, you might have an icon that should always be there, regardless of whether the button renders as a <button>, an <a>, or a <Link>.

The problem? If we pass multiple children to our Slot, the cloning engine will fail because it doesn't know which child is the "target" and which is just extra UI (like the icon).

// button.tsx
import React from 'react';
import { Slot } from '../slot';

interface Props {
  children?: React.ReactNode;
  icon?: React.ReactNode;
  asChild?: boolean;
}

export function Button({ children, icon, asChild }: Props) {
  const Component = asChild ? Slot : 'button';

  return (
    <Component
      className='flex items-center gap-2 bg-blue-500 rounded-full px-4 py-2 text-white hover:bg-blue-600'
      onClick={() => alert('Hey!')}
    >
      {children}
      {icon}
    </Component>
  );
}

// component.tsx
import { BoardIcon } from '@icons/outline';
import { Button } from './button';

export default async function Home() {
  return (
    <Button icon={<BoardIcon />} asChild>
      <div>Test</div>
    </Button>
  );
}
Enter fullscreen mode Exit fullscreen mode

React error stating that only one React element child can be received

The Solution: The Slottable Marker

We need a way to "mark" the specific child that should receive the Slot's properties. We do this by creating a tiny utility component called Slottable. It doesn't do anything on its own; it simply acts as a lighthouse for our Slot engine.

Just like we used a factory for our Slot, we use a factory for the Slottable marker. This allows us to maintain a naming convention that links the two components together.

// Use a symbol as unique identifier and to ensure there are zero 
// collisions with other libraries or user-defined properties
const SLOTTABLE_ID = Symbol('SLOTTABLE_ID');

export function createSlottable(ownerName: string) {
  const Slottable = ({ children }: { children: React.ReactNode }) => (
    <>{children}</>
  );

  Slottable.displayName = `${ownerName}.Slottable`;
  Slottable.__id = SLOTTABLE_ID;

  return Slottable;
}

function isSlottable(
  child: any,
): child is React.ReactElement<SlotProps, typeof Slottable> {
  return (
    React.isValidElement(child) &&
    typeof child.type === 'function' &&
    '__id' in child.type &&
    (child.type as any).__id === SLOTTABLE_ID
  );
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: Type Predicates for robust identification

You’ll notice our isSlottable function uses a specific TypeScript syntax: child is React.ReactElement. This is known as a Type Predicate.
By using a Type Predicate combined with our unique SLOTTABLE_ID, we achieve two critical things:

  • Runtime Certainty: We ensure that the component we are about to clone is definitely our Slottable marker and not just a random div or a third-party component that happens to have a similar structure.
  • Compile-Time Safety: Once this function returns true, TypeScript "narrows" the type. This means inside our if block, the compiler knows exactly what properties are available on the child, allowing us to access child.props.children without using unsafe type assertions (like as any).

Integrating it into the Slot Logic

With our isSlottable helper ready, we can now integrate it into the createSlot factory. This allows the Slot to scan its children, identify the 'real' element intended for cloning, and correctly re-map siblings like icons or labels.:

function createSlot(ownerName: string) {
  const SlotClone = createSlotClone(ownerName);

  const Slot = ({ children, ...slotProps }: SlotProps) => {
    // Look for our Slottable component
    const childrenArray = React.Children.toArray(children);
    const slottable = childrenArray.find(isSlottable);

    if (slottable) {
      // The 'newElement' is the actual component 
      // wrapped inside the <Slottable> marker
      const newElement = slottable.props.children;

      const newChildren = childrenArray.map((child) => {
        if (child === slottable) {
      // Instead of rendering the Slottable or the newElement here, 
          // we only return the newElement's children
          // This avoids duplicating the element when we clone it later
          return React.isValidElement(newElement)
            ? (newElement.props as { children: React.ReactNode }).children
            : null;
        }
        return child;
      });

      // Clone the newElement, but inject the 'newChildren'
      // (which now includes the original siblings) back into it
      return (
        <SlotClone {...slotProps}>
          {React.isValidElement(newElement)
            ? React.cloneElement(newElement, undefined, newChildren)
            : null}
        </SlotClone>
      );
    }

    return <SlotClone {...slotProps}>{children}</SlotClone>;
  };

  Slot.displayName = `${ownerName}.Slot`;
  return Slot;
}

// Create and export the Slottable component
export const Slottable = createSlottable('Slottable');
Enter fullscreen mode Exit fullscreen mode

Graph showing the process of how a multiple-children slot works

Putting it into Practice: The Polymorphic Button

Now that we have built the Slot and Slottable infrastructure, let's look at how this simplifies our daily development. We can create a single Button component that adapts to its context without messy conditional logic.

1. The Button Component

Notice how we use the asChild prop to determine whether to render our custom Slot or a native button. We also use the Slottable marker to ensure that even when the button is swapped for a link, our internal layout remains stable.

// button.tsx
import React from 'react';
import { createSlot, createSlottable } from '../slot';

const Slot = createSlot('Button');
const Slottable = createSlottable('Button');

interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  asChild?: boolean;
  icon?: React.ReactNode;
}

export function Button({ children, icon, asChild, ...props }: Props) {
  // If asChild is true, we use our Slot engine; otherwise, a native button.
  const Component = asChild ? Slot : 'button';

  return (
    <Component
      {...props}
      className="flex items-center gap-2 bg-blue-500 rounded-full px-4 py-2 text-white hover:bg-blue-600 font-semibold"
      onClick={() => alert('Logic from Button component!')}
    >
      <Slottable>{children}</Slottable>
      {icon}
    </Component>
  );
}
Enter fullscreen mode Exit fullscreen mode

2. Three Implementations, One Component

Now, look at how the consumer can use this Button in three entirely different ways. Regardless of the underlying element, the CSS classes and the onClick alert will be applied to all of them.

// app.tsx
import { BoardIcon } from '@icons/outline';
import { Button } from './button';
import Link from 'next/link';

export default function Page() {
  return (
    <div className="flex flex-col gap-4">
      <Button icon={<BoardIcon />}>
        Native Button
      </Button>

      <Button asChild icon={<BoardIcon />}>
        <a href="https://google.com" target="_blank">
          External Link
        </a>
      </Button>

      <Button asChild icon={<BoardIcon />}>
        <Link href="/dashboard">
          Client-side Navigation
        </Link>
      </Button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Result of applying the slot pattern to three implementations of the same button

Conclusion

The Slot pattern is a super useful tool for design systems. By leveraging Type Predicates, and Symbol-based identification, we have created a component that:

  • Is Type-Safe: TypeScript knows exactly what is happening at every step.
  • Is Highly Reusable: Consumers have total control over the underlying element while the design system maintains visual and behavioral consistency.

Top comments (0)