29 Dec 2022

asChild in React, Svelte, Vue, and Solid for render delegation

Designing polymorphic components.

Introduction

When building a web component library, it is often useful to give users the ability to customize the underlying element or component to use. For example, a dialog component may use a <div> by default for its modal, but could allow users to substitute the <div> for a <section> or a custom <Box> element instead.

It isn’t enough to simply ask users to wrap the components with the desired element i.e. <section><Modal>...</Modal></section> as this introduces many issues:

  1. certain styling frameworks may use CSS selectors that would break when the HTML structure changes,
  2. users would have to separate and apply styles based on internal (display, padding, etc.) or external (position, margin, etc.) effects,
  3. custom behaviors may break or behave unexpectedly when nested in other components,
  4. certain HTML elements must be organized properly e.g. <thead> in <table>, and
  5. wrapping components in extra elements leads to div soup.

Polymorphism is a natively supported feature in libraries like React and Svelte, but I believe we can take it one step further by implementing what I like to call the render delegation pattern.

I ran into this problem while building Ally UI, a component library for React, Svelte, Vue, and Solid. We’ll look at some existing solutions for customizing the underlying elements of components, then present Ally UI’s solution for render delegation in all of its supported UI libraries.

Polymorphism with as

Some libraries provide an as property on their components which allows users to specify a specific component or HTML tag to use.

() => (
  <Button as="a" href="#">
    Back to top
  </Button>
);

However, what do we do when we want to compose multiple custom components together? e.g. if we have a custom <Link> component that should be used to render a <Button> component. Although this is possible, I’ve always felt like its implementation and usage gets complicated quickly.

() => (
  // How do we use a custom component for `<Link />` as well?
  <Button as={Link} href="#">
    Back to top
  </Button>
);

Delegating render with asChild

Radix UI handles this in React with the asChild pattern on their <Slot> utility component. Simply put, asChild delegates the rendering of a component to its single child element.

() => (
  <Button asChild>
    <Link href="#">
      Back to top
    </Link>
  </Button>
);

Although this pattern is quite powerful, Radix UI's implementation is only possible in React.

Given the flexibility of render delegation with asChild, we want to adapt this pattern to other UI libraries like Svelte, Vue, and Solid. In building Ally UI, we’ve developed viable adaptations that work wonderfully and we’ll explore their design considerations below.

Merging props and node references

One behavior to note with render delegation is that props and event handlers can be defined in two locations — on the parent component and on the child component.

() => (
  <Box asChild tabindex="0" onClick={log}>
    <section href="#" tabindex="-1" onClick={alert}>
      content
    </section>
  </Box>
);

Therefore, we have to define rules that make the process of merging parent and child props predictable and intuitive. To summarize:

  1. if a prop exists on both, the child prop overrides the parent prop
  2. if an event handler exists on both, both handlers are called with the child handler being called before the parent handler.
  3. if a class or className prop exists on both, both class lists are joined.
  4. if a style prop exists on both, they are merged with the child styles overriding the parent styles.
  5. DOM node references are provided to both the user and the parent component’s internal handlers, either in the form of React’s callback refs or Svelte’s bind:this.

React

() => (
  <Button asChild>
    <Link href="#">
      Back to top
    </Link>
  </Button>
);

Implementation

Building the <Slot> component

React allows components to receive multiple ReactNode child elements, which is a wide type that captures all possible values used in React JSX.

// @types/react/index.d.ts
type ReactNode = 
  | ReactElement
  | string
  | number
  | ReactFragment
  | ReactPortal
  | boolean
  | null
  | undefined;

Since React 18, ReactNode no longer includes ReactNodeArray. This lets us more easily differentiate components that receive a single child from components that can receive multiple children. Because we want to delegate rendering to a single element, we can specify that contract in our prop type definition.

interface SlotProps {
  children?: React.ReactNode; // Only one child allowed.
}

Next, we need to check that the child node can receive props and provide a reference to a DOM node. This can be checked with React.isValidElement. If the child node isn’t an element, we simply return null.


export const Slot = React.forwardRef<HTMLElement, SlotProps>(
  (props, forwardedRef) => {
    const {children, ...slotProps} = props;

    if (!React.isValidElement(children)) {
      return null;
    }

    // ...

Children in React are immutable by default. Instead, we have to use React.cloneElement to create a mutable clone of the child element which lets us override props and define custom behaviors.

    // ...
    return React.cloneElement(children, {
      ...mergeReactProps(slotProps, children.props),
      ref: combinedRef([forwardedRef, (children as any).ref]),
    } as any);
  },
);

We’ve defined custom mergeReactProps and combinedRef functions which implement the necessary prop merging behaviors defined above.

Usage

To create a custom component with <Slot>, accept an asChild prop and select the right component using React’s polymorphism. For example, we can define <Box> as:

export const Box = React.forwardRef<HTMLElement, BoxProps>(
  (props, forwardedRef) => {
    const {children, asChild, ...restProps} = props;
    const Comp = asChild ? Slot : 'div';
    return (
      <Comp
        {...restProps}
        ref={forwardedRef}
      >
        {children}
      </Comp>
    );
  },
);

<Box> can then be used like so:

() => (
  <Box>in a regular div</Box>
  <Box asChild>
    <section>
      in a section
    </section>
  </Box>
);

Svelte

<Button asChild let:ref let:props>
  <Link use={[ref]} {...props({href: '#'})}>
    Back to top
  </Link>
</Button>

Svelte has never been the easiest UI library to extend and build custom components for. Many Svelte features (transition, animation, action, class, and style directives) that are available on native HTML elements are not available to components. Many Svelte library developers have created amazing workarounds for some of these features, but they are ultimately not as ergonomic as Svelte natively.

☁️

The lack of event delegation makes it difficult to flexibly handle component events, although there’s an amazing workaround that I came across in rgossiaux’s Svelte port of Headless UI.

If there is enough interest, I would be more than happy to write about building flexible Svelte components, but that’s for another article. Today, we’ll focus on render delegation for Svelte.

Implementation

Unlike its React implementation, we cannot extract render delegation behavior into a separate component in Svelte. Instead, we define deferred rendering behavior directly on the component.

Passing props

To set props on the delegated render element, we can use slot props. However, because we want control over how props are merged, we cannot simply pass a prop object.

<Box asChild let:props class="flex">
  <!-- "p-4" will wrongly override "flex" -->
  <section {...props} class="p-4">
    content
  </section>
</Box>

Instead, we use the render prop getter pattern summarized wonderfully by Kent C. Dodds.

<Box asChild let:props class="flex">
  <!-- Box can now handle prop merging internally -->
  <section {...props({'class': 'p-4'})}>
    content
  </section>
</Box>

Although this isn’t as ergonomic or syntactically neat, it is the best compromise possible given Svelte’s limitations.

Within <Box>, we then use $$restProps to forward any additional props on the component to the internal element or render prop getter.

<script lang="ts">
	// ...
  const getProps = (userProps: svelteHTML.IntrinsicElements['div']) =>
    mergeSvelteProps(
      $$restProps,
      userProps,
    );
  // ...
</script>

{#if asChild}
  <slot {ref} props={getProps} />
{:else}
  <div use:ref {...$$restProps}>
    <slot />
  <div>
{/if}

We define a mergeSvelteProps helper to properly merge props.

Getting a reference to slot contents

Components may need a reference to their DOM nodes even with render delegation. However, Svelte uses a templating language with slots for defining where nested content will be rendered. As such, we cannot directly get a reference to a component’s children. To work around this, we take advantage of slot props and actions.

<Box asChild let:ref let:props>
  <section use:ref {...props()}>
    content
  </section>
</Box>

Actions are simply functions that (upon mounting) receive a reference to the DOM node where the use directive is applied. The relevant sections of <Box> are simply defined as:

<script lang="ts">
  // ...
  export let node: HTMLElement | null = null;
  const ref = (n: HTMLElement) => (node = n);
  // ...
</script>

{#if asChild}
  <slot {ref} props={getProps} />
{:else}
  <div use:ref {...props}>
    <slot />
  <div>
{/if}

When <Box> is mounted, node will be a reference to the default <div> or the delegated render element if asChild is true.

Handling events

Components may also need to define event handlers on their DOM nodes. Because these events will only be fired after the component is mounted, we can simply attach event listeners on the node reference and remove them on unmount.

For convenience, we define event handlers within the ref action using a custom createRefAction helper function. This automatically attaches the event listeners when ref is activated and removes them when ref is destroyed.

<script lang="ts">
  // ...
  const ref = createRefAction((n) => (node = n), {
    click: [handleClick, {passive: true}],
  });
  // ...
</script>

TypeScript support

The last step is to provide proper types for the component. We want the ref and props slot props to only be available when asChild is true.

Svelte components support generics through the special $$Generic type. To customize the prop and slot definitions of a Svelte component, we can also use the special $$Props and $$Slots types. These types allow us to override Svelte’s intrinsic type definitions to a certain degree.

We first define asChild to be a generic TAsChild type that extends true | undefined.

<script lang="ts">
  // ...
  type TAsChild = $$Generic<true | undefined>;
  export let asChild: TAsChild = undefined as TAsChild;
  // ...
</script>

We then specify that <Box> should inherit all the props of <div>.

<script lang="ts">
  // ...
  type $$Props = svelteHTML.IntrinsicElements['div'] & {
    node?: HTMLDivElement | null;
    asChild?: TAsChild;
  };
  // ...
</script>

Lastly, we indicate that the default slot receives no slot props if asChild = undefined, or receives the prop getter and ref action otherwise.

<script lang="ts">
  // ...
  type $$Slots = {
    default: undefined extends TAsChild
      ? Record<string, never>
      : {
        props: SlotRenderPropGetter<
          BoxProps,
          svelteHTML.IntrinsicElements['div']
        >,
        ref: RefAction<{
          click: [(ev: Event) => void, undefined]
        }>,
      };
  };
  // ...
</script>

Usage

<Box> can then be used like so:

<Box>in a regular div</Box>
<Box asChild let:ref let:props>
  <section use:ref {...props()}>
    in a section
  </section>
</Box>

Vue

<Button as-child v-slot="props">
  <Link v-bind="props">
    Back to top
  </Link>
</Button>

For this article, we will be discussing Vue 3 with its Composition API in Single-File Components.

Implementation

Passing props

Like Svelte, we define the render delegation pattern directly on the component. However, Vue simplifies many of the pain points with Svelte. For example, instead of using object spreading, we use v-bind to apply an object of properties to an element. v-bind properly handles props like class and style, thereby removing the need for render prop getters.

We can simply use $attrs within the template to get all props passed into a component. For render delegation, we pass the properties into the slot with v-bind, and later access the slot props with v-slot.

<script setup lang="ts">
// ...
export type BoxProps = {
  asChild?: true | undefined;
};
const props = withDefaults(defineProps<BoxProps>(), {
  asChild: undefined,
});
// ...
</script>

<template>
  <slot
    v-if="props.asChild"
    v-bind="$attrs"
  />
  <div
    v-else
    v-bind="$attrs"
  >
    <slot />
  </div>
</template>

Event handling

Vue uses v-on:{type}="handler" or @{type}="handler" to attach event handlers. When these handlers are attached to a component, they are exposed as on{Type} in the $attrs object. on{Type} will also be attached as an event by v-bind automatically. Therefore, no additional code is required to handle events with render delegation.

<template>
  <slot
    v-if="props.asChild"
    v-bind="{
      ...$attrs,
      onClick: handleClick,
    }"
  />
  <div
    v-else
    v-bind="$attrs"
    @click="handleClick"
  >
    <slot />
  </div>
</template>

Getting a reference to slot contents

In Vue, ref is a special attribute that lets us obtain a reference to a DOM node after mounting. Generally, ref accepts a string that defines the name of the variable to assign the reference to. However, ref strings are not sufficient if we want to pass references back up through slots.

Vue 2 used to support callback refs with :ref that would be called when a node is mounted to the DOM. However, it was removed in 2.2 as it caused many bugs internally.

In Vue 3, I’ve noticed that callback refs still work as long as they are passed through v-bind into a slot. I can’t find any documentation on this and the behavior might regress in the future, but due to the lack of viable alternatives, this is the solution that I decided on for now.

<script setup lang="ts">
// ...
const node = ref<HTMLElement | null>(null);
const setRef = (value: HTMLElement | null) => (node.value = value);
// ...
</script>

<template>
  <slot
    v-if="props.asChild"
    v-bind="{
      ...$attrs,
      ref: setRef,
    }"
  />
  <div
    v-else
    ref="node"
    v-bind="$attrs"
  >
    <slot />
  </div>
</template>

Usage

<Box> can then be used like so:

<Box>in a regular div</Box>
<Box as-child v-slot="props">
  <section v-bind="props">
    in a section
  </section>
</Box>

Solid

() => (
  <Button asChild>
    {(props) => (
      <Link {...props({href: '#'})}>
        Back to top
      </Link>
    )}
  </Button>
);

Due to how Solid compiles JSX down to raw DOM elements, there aren’t any solutions outside of using the JSX function child pattern. However unlike with React, this pattern does not incur a performance penalty because Solid only runs the component function once.

Implementation

Passing props

The same render prop getter pattern is used to properly merge props from the parent and child component. One notable difference is the inclusion of Solid’s special class and classList properties.

Event forwarding

To improve performance, Solid supports an array syntax for its event handlers that allows users to call the specified handler with data without creating additional closures.

() => (
  <button onClick={[handler, data]}>Click me</button>
);

Therefore, we have to account for these event handler values when merging event handlers.

export function forwardEvent<TTarget, TEvent extends Event>(
  ev: TEvent & {
    currentTarget: TTarget;
    target: Element;
  },
  handler?: JSX.EventHandlerUnion<TTarget, TEvent>,
) {
  if (handler instanceof Function) {
    handler(ev);
  } else if (handler != null) {
    handler[0](handler[1], ev);
  }
}

Usage

<Box> can then be used like so:

() => (
  <Box>in a regular div</Box>
  <Box asChild>
    {(props) => (
      <section {...props()}>
        in a section
      </section>
    )}
  </Box>
);

Conclusion

Whether you’re building a component library for React, Svelte, Vue, or Solid, I hope this article has convinced you to adopt the render delegation pattern for your components. These lessons have been developed and discovered through a lot of effort, and I hope it helps you in building your own library!

If you need more detailed examples, you can explore how these patterns are used in Ally UI.