Building Flexible UI Libraries: The Render Delegation Pattern in React, Svelte, Vue, and Solid

A guide to building extremely polymorphic component libraries.

29 Dec 2022

Introduction

When building a web component library, flexibility is paramount. Developers often need to customize the underlying HTML elements of components to meet specific design, accessibility, and functionality requirements. However, simply wrapping components in custom elements like <section> or <Box> can introduce a range of challenges, such as CSS selector conflicts, complex styling management, unexpected behaviors in nested components, and the notorious "div soup."

To address these challenges, libraries like React and Svelte offer basic polymorphism, allowing one component to be substituted for another. But what if we could take this concept even further? In this post, I’ll explore a more advanced solution that I’ve implemented in Ally UI — a component library for React, Svelte, Vue, and Solid. I call it the render delegation pattern.

Polymorphism with as

Many component libraries provide an as property that lets developers specify which HTML tag or component should be rendered.

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

This approach works well when substituting standard HTML elements. But what happens when you want to compose multiple custom components? For example, suppose you have a custom <Link> component that you want to use inside a <Button> component. While it's possible to pass components via as, you would quickly run into issues.

() => (
  // How do we pass props to `<Link />`?
  <Button as={Link} href="#">
    Back to top
  </Button>
);

Introducing Render Delegation with asChild

Radix UI offers a solution to this problem in React through the asChild pattern, implemented in their <Slot> utility component. The asChild pattern delegates the rendering of a component to its single child element, making component composition much simpler.

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

This pattern is powerful, but Radix UI's implementation is exclusive to React. The flexibility of render delegation with asChild is something I wanted to adapt for other UI libraries like Svelte, Vue, and Solid. In building Ally UI, I’ve developed adaptations that work seamlessly across these frameworks, and I’ll walk through the design considerations and implementation details below.

Merging props, event handlers, and node references

One of the complexities of render delegation is managing how props and event handlers are applied. Props and handlers can be defined on both the parent and child components, raising the question of how to merge them predictably and intuitively.

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

To handle this, I've defined clear rules for merging:

  1. Prop precedence: If a prop exists on both the parent and child, the child prop overrides the parent prop.
  2. Event handler order: If an event handler exists on both, both handlers are called, with the child handler executing first.
  3. Class merging: If both parent and child define class or className, the class lists are combined.
  4. Style merging: If both define style, the styles are merged, with child styles taking precedence.
  5. DOM node references: Node references are provided to both the user and the parent component's internal handlers, using 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 inspired you to consider the render delegation pattern for your components. These insights are the result of extensive development and experimentation, and I hope they prove valuable as you create your own library.

For more detailed examples, take a look at how these patterns are implemented in Ally UI.