Introduction

beqa/react-slots - Responsible React Parenting

react-slots empowers you to prioritize composability in your component APIs.

The core of react-slots is the slot pattern. It's designed to provide all the features you'd find in Vue and Svelte's slot implementations while keeping things familiar for React developers. This slot pattern, complemented by great type inference features and an intuitive API for manipulating nodes, allows you to design highly composable components with previously unimagined patterns in React.

What is react-slots for

The slot pattern enables you to break down parent-provided content into multiple parts and instruct a component on where to render each part by placing slots.

The slot pattern works best for utility components within your design system. These components don't depend on any specific shape of data and work with primitives as much as possible. You can think of react-slots as a way to "extend" the standard HTML element API.

react-slots might be the missing piece you're looking for if:

  • You are building a design system from scratch.
  • Your reusable components eventually start to have a giant list of props, with which the parent has to control every aspect of abstracted logic.
  • You are looking for a nice way to maintain the hierarchy of headlines while being able to correctly style your titles.
  • You are looking for a way to abstract away handling ARIA attributes.
  • You keep reimplementing the same components for every project to meet the new styling or functional requirements

How it works

You can access the slot magic with the useSlot hook. useSlot takes in the children and returns a slot object for this component. slot is a dynamic object, where any key gives you access to corresponding parent-provided content. Values on the slot object can be used as a React element, providing a nice syntax to specify fallback content and a place to define props that you can pass up to the parent.

Consider the ListItem component. It has slots for the title, description, and thumbnail. It's implemented to hide large descriptions by default but has the ability to expand when clicked. Since it handles expanding logic on its own, we might benefit from passing the isOpen prop up, allowing the parent to access it for any custom logic. To enhance usability, we will make the description a default slot, meaning any unspecified slot content will end up in the description implicitly.

import { useSlot } from "@beqa/react-slots";
 
function ListItem({ children }) {
  const { slot } = useSlot(children);
  const [isExpanded, setIsExpanded] = useState(false);
 
  return (
    <li
      className={`${isExpanded ? "expanded" : "collapsed"}`}
      onClick={() => setIsExpanded(!isExpanded)}
    >
      {/* Render thumbnail if provided, otherwise nothing*/}
      <slot.thumbnail />
      <div>
        {/* Render a fallback if title is not provided*/}
        <slot.title>Expand for more</slot.title>
        {/* Render the description and pass the prop up to the parent */}
        <slot.default isExpanded={isExpanded} />
      </div>
    </li>
  );
}

To draw a parallel with the "Render Props" pattern, similar logic would be implemented like this with plain props:

function render(prop, fallback, passUpProps) {
  if (typeof prop === "function") {
    return prop(passUpProps || {});
  } else if (prop === undefined || prop === null) {
    return fallback;
  }
  return prop;
}
 
function ListItem({ children, title, thumbnail }) {
  const [isExpanded, setIsExpanded] = useState(false);
 
  return (
    <li
      className={`${isExpanded ? "expanded" : "collapsed"}`}
      onClick={() => setIsExpanded(!isExpanded)}
    >
      {render(thumbnail, null)}
      <div>
        {render(title, "Expand for more")}
        {render(children, null, { isExpanded })}
      </div>
    </li>
  );
}

Specifying Slot Content From the Parent

There are three ways to specify slot content from the parent, one of which is 100% type-safe (We'll explore the type-safe way in the dedicated type-safety section on this page). One way to specify slot content is by including a slot-name attribute on any element or component with a value corresponding to the available slot for the component. The other way is by using a template. A template is a special object, similar to a slot, where values are React elements that correspond to the slot with the same name. They are superior to slot-name because:

  • You can provide primitives to a slot without having to wrap them in an arbitrary element.
  • They can access props that are passed up by corresponding slots.
<ListItem>
  <img slot-name="thumbnail" src="..." />
  <div slot-name="title">A title</div>
  this is a description
</ListItem>;

Why Not Just Use Props?

To be clear, this pattern doesn't prohibit the use of render props. You can have both in the same component to provide a rich API for your components.

The problem with using render props in this manner is that it restricts composability. There might initially not be a problem with the abstraction created using the render props pattern, but that abstraction usually breaks when you introduce the second or third layer of composition.

Composition with Render Props

To illustrate the problem, let's see how we would expand on the ListItem component to abstract away common logic that might arise from using ListItem elements often. Since we usually render ListItem in a list where we like to keep only one ListItem expanded at once, we can make a SingleExpandList component.

We'll have to change the implementation of ListItem to accept the isExpanded prop. Since we don't want to break the existing logic that assumes that ListItem doesn't require the isExpanded prop, we can use a useControlledState hook. useControlledState makes it possible for state to be both controlled and uncontrolled. There are many implementations of this hook, one of which can be found here: useControlledState by niekert (opens in a new tab)

function ListItem({ children, title, thumbnail, isExpanded, onExpand }) {
  const [_isExpanded, setIsExpanded] = useControlledState(isExpanded, onExpand);
 
  return (
    // Everything else remains the same
  );
}

A typical way to implement SingleExpandList using ListItem is as follows:

type Props = {
  items: {
    title: React.ReactNode;
    thumbnail: React.ReactNode;
    description: (({ isExpanded }) => React.ReactNode) | React.ReactNode;
    id: string;
  }[];
  onExpand: (expandedId) => void;
  expandedId: string;
};
 
function SingleExpandList({ items, expandedId, onExpand }) {
  const [_expandedId, setExpandedId] = useControlledState(expandedId, onExpand);
 
  return (
    <ul>
      {items.map((item) => (
        <ListItem
          isExpanded={_expandedId === item.id}
          onExpand={() => onExpand(_expandedId)}
          title={item.title}
          thumbnail={item.thumbnail}
        >
          {item.description}
        </ListItem>
      ))}
    </ul>
  );
}

While this component accomplishes its job, all the benefits of composability are lost at this point.

  • What if you want to add a divider between the second and third items?
  • What if the first ListItem needs to be styled differently?
  • What if the fourth ListItem needs to be always open?

Composition with Slots

One way react-slots addresses this issue is with the OverrideNode element. While the name may sound intimidating, it's actually an easy and type-safe way to define strict relationships between composed elements. You start by including <OverrideNode /> as a direct child of the slot element. You may use one or many OverrideNode's per slot, and depending on whether you wrap the fallback with OverrideNode, the override will apply to the fallback as well.

import { OverrideNode, useSlot } from "@beqa/react-slots";
 
function SingleExpandList({ children, expandedId, onExpand }) {
  const { slot } = useSlot(children);
  const [_expandedId, setExpandedId] = useControlledState(expandedId, onExpand);
 
  return (
    <ul>
      <slot.default>
        <OverrideNode
          allowedNodes={[ListItem]}
          enforce="ignore" // Override ListItem, Keep everything else intact
          props={(props) => {
            if (!props.id) {
              console.error(
                "When using SingleExpandList, each ListItem should have a unique id",
              );
            }
 
            return {
              // Override isExpanded on ListItem to be controlled by SingleExpandList
              isExpanded: _expandedId === el.id,
              // Call this component's onExpand through useControlledState when the event fires
              onExpand(nextIsExpanded) {
                if (nextIsExpanded) {
                  setExpandedId(el.id);
                } else if (!nextIsExpanded && el.id === _expandedId) {
                  setExpandedId(null);
                }
                // Also execute the original onExpand on the ListItem
                if (props.onExpand) {
                  props.onExpand(nextIsExpanded);
                }
              },
            };
          }}
        />
      </slot.default>
    </ul>
  );
}

Since we are overriding default slot content, OverrideNode will apply to every top-level child of SingleExpandList.

SingleExpandList in the above example accomplishes these:

  • It wraps any free-form content.
  • Everywhere in the children where it sees the ListItem component, it overrides the onExpand prop without stealing the original handler.
  • It overrides the isExpanded prop, so if any ListItem has that prop specified, it will be ignored, and the only source of truth will come from SingleExpandList.
  • It ignores every other node, so the parent can specify custom components for dividers, headings and so on.

OverrideNode is just one way to accomplish this. Depending on the situation, you may or may not need it. In the advanced examples below, Dialog and DialogTrigger use React Context instead of OverrideNode to expose a similar API.

Usage

<SingleExpandList>
  <ListItem id={1}>
    <img slot-name="thumbnail" src="..." />
    <div slot-name="title">A title</div>
    this is a description
  </ListItem>
  <ListItem id={2}>
    <img slot-name="thumbnail" src="..." />
    <div slot-name="title">Second title</div>
    Second description
  </ListItem>
</SingleExpandList>

OverrideNode API is explained in detail in sections from Manipulating Slot Content to Node Prop in the tutorial

Type-Safety

react-slots is designed to provide a great developer experience by allowing you to write less TypeScript to infer more. The source of truth in react-slots comes from two types: SlotChildren and Slot, which are used to annotate your children. The API of Slot and SlotChildren is explained in detail in the type-safety section of the tutorial.

To begin, let's make our ListItem and SingleExpandList type-safe:

import { Slot, SlotChildren, ... } from "@beqa/react-slots";
 
type ListItemProps = {
	children: SlotChildren<
		| Slot<"title"> // Shorthand of Slot<"title", {}>
		| Slot<"thumbnail"> // Shorthand of Slot<"thumbnail", {}>
		| Slot<{ isExpanded: boolean }> // Shorthand of Slot<"default", {isExpanded: boolean}>
	>;
	isExpanded: boolean;
	onExpand: (nextIsExpanded: boolean) => void;
	id: string;
};
 
function ListItem({ children, isExpanded, onExpand, id }: ListItemProps) {
	const { slot } = useSlot(children); // Returns type-safe slot with correct keys and assignable props
	// implementation remains the same
}
 
type SingleExpandListProps = {
	children: SlotChildren<Slot>; // Has only default slot with no passed-up props
	expandedId: string;
	onExpand: (nextExpandedId: string) => void;
};
 
function SingleExpandList({ children, expandedId, onExpand }: SingleExpandListProps) {
	const { slot } = useSlot(children); // Returns type-safe slot that has only "default" key
	// implementation remains the same
}
 

The syntax of SlotChildren and Slot types is designed to be easy to read at a glance, so you won't have to wonder if a component in your codebase uses slots or not. There's also a handy trick to make component slots visible by hovering on a component without going to a file.

Please note that we don't have to change OverrideNode in any way because it's type-safe by default. Its type is inferred from the array you pass to allowedNodes. This means you can define complex override logic, and you'll get a type error if the element you are overriding changes its implementation. The downside of OverrideNode itself is that it cannot enforce the type of allowed nodes on the type level; instead, a development only run-time error is thrown by default when the parent provides the wrong type of node. In the production, disallowed nodes are removed

To make parents type-safe, all you have to do is use type-safe templates. You can create type-safe templates with either the CreateTemplate type or a createTemplate function, for which you have to provide the original children type.

// Option #1
import { createTemplate } from "@beqa/react-slots";
const template = createTemplate<ListItemProps["children"]>();
 
// Option #2
import { template, CreateTemplate } from "@beqa/react-slots";
const template = template as CreateTemplate<ListItemProps["children"]>;
 
// Typo-free and auto-complete for props!
<ListItem>
  <template.thumbnail>
    <img src="..." />
  </template.thumbnail>
  <template.title>A title</template.title>
  <template.default>
    {({ isExpanded }) =>
      isExpanded ? <strong>A description</strong> : "A description"
    }
  </template.default>
  <template.default>doesn't have to be a function</template.default>
</ListItem>;

Typescript can't enforce that the slot-name attribute is correct, so it's recommended not to use the slot-name attribute until your component API is stable. Alternatively, you need to be careful when changing slot names during refactoring. Please note that if the parent provides a wrong slot-name, the element won't be rendered, and the user will see the fallback instead.

Slot, SlotChildren, and CreateTemplate APIs are explained in detail in the Type-Safety section of the tutorial.

Caveats

react-slots "steals" the children by requiring its shape to be specific to react-slots. This means you can only use the children inside the component to provide it to useSlot. All of React.Children APIs cannot meaningfully work with slotted component children. Instead, react-slots exposes OverrideNode to replace almost all the use cases for React.Children APIs.

There's also some "quirkiness" to slotted component children that's documented here:

Advanced Examples

This examples are meant to demonstrate how to create flexible component APIs with composition. Checkout live examples to see all the different ways to use these components.

Creating highly composable Accordion and AccordionList components using react-slots

Checkout live example (opens in a new tab)

<AccordionList>
  <Accordion key={1}>
    <span slot-name="summary">First Accordion</span>
    This part of Accordion is hidden
  </Accordion>
  <Accordion key={2}>
    <span slot-name="summary">Second Accordion</span>
    AccordionList makes it so that only one Accordion is open at a time
  </Accordion>
  <Accordion key={3}>
    <span slot-name="summary">Third Accordion</span>
    No external state required
  </Accordion>
</AccordionList>

Creating highly composable Dialog and DialogTrigger components using react-slots

Checkout live example (opens in a new tab)

<DialogTrigger>
  <Button>Trigger Dialog</Button>
  <Dialog slot-name="dialog">
    <span slot-name="title">Look Ma, No External State</span>
    <p slot-name="content">... And no event handlers.</p>
    <p slot-name="content">Closes automatically on button click.</p>
    <p slot-name="content">Can work with external state if desired.</p>
    <Button
      slot-name="secondary"
      onClick={() => alert("But how are the button variants different?")}
    >
      Close??
    </Button>
    <Button slot-name="primary">Close!</Button>
  </Dialog>
</DialogTrigger>

If you like this project please show support by starring it on Github (opens in a new tab)

type Props = {
  // a function that renders content or just content;
  summary: SlotProp;
  // a function that receives isOpen and returns content or just content;
  icon: SlotProp<{ isOpen: boolean }>;
  // ... same as before
  children: SlotChildren<Slot<{ isOpen: boolean }>>;
};
 
function Accordion(props: Props) {
  const { slot } = useSlot({
    summary: props.summary,
    icon: props.icon,
    children: props.children,
  });
 
  return (
    <div>
      <h1>
        <slot.summary />
        <slot.icon isOpen={true} />
      </h1>
      <h1>
        <slot.default isOpen={true} />
      </h1>
    </div>
  );
}
 
<Accordion summary="summary" icon={"<"}>
  content
</Accordion>;