Advanced Element Composition in React

Ryan Florence -

(I wrote this a couple years ago in a gist)

In React you can wrap up any elements into a component and then render the new component instead. It's beautiful.

// old
<h1>Time zones</h1>
<select>
  <option>Eastern</option>
  <option>Central</option>
  <option>Mountain</option>
  <option>Pacific</option>
  <option>UTC-10</option>
  <option>UTC-09</option>
  <option>UTC-09:30</option>
  {/* etc. */}
</select>

// new
<h1>Time zones</h1>
<select>
  <LocaleTimeZoneOptions/>
  <UTCTimeZoneOptions/>
</select>

function LocaleTimeZoneOptions() {
  return (
    <>
      <option>Eastern</option>
      <option>Central</option>
      <option>Mountain</option>
      <option>Pacific</option>
    </>
  )
}

Everything will continue to work!

But when we want to create our own abstractions like this we can't always abstract and compose the same way.

The Menu here will set an aria-activedescendant={activeElementId} so that assistive tech can announce correctly. The menu also needs a ref to the children so it can set them as the active descendant (or actually focus the node) from keyboard events like ArrowUp and ArrowDown.

Additionally, MenuItem needs to know if it is the active descendant so it can style itself differently.

<Menu>
  <MenuItem onSelect={download}>Download</MenuItem>
  <MenuItem onSelect={save}>Save</MenuItem>
  <MenuItem onSelect={preview}>Preview</MenuItem>
</Menu>

There are a few ways to deal with this.

Option 1: Bail out of Elements

The solution most people turn to is to bail out of the element API and turn to arrays. This lets a single owner control the state and rendering, makes it way easier to know the index and set the active-descendant.

<Menu
  items={[
    { label: "Download", onSelect: download },
    { label: "Save", onSelect: save },
    { label: "Preview", onSelect: preview }
  ]}
/>;

function Menu({ items }) {
  const [activeIndex, setActiveIndex] = useState();
  return (
    <div data-menu aria-activedescendant={activeIndex}>
      {items.map((item, index) => (
        <MenuItem
          // easy to tell the index
          index={index}
          onSelect={item.onSelect}
        >
          {item.label}
        </MenuItem>
      ))}
    </div>
  );
}

function MenuItem({ index, onSelect, children }) {
  // and now we can style
  const isActive = index === activeIndex;
  return (
    <div
      // and add an ID
      id={index}
      data-active={isActive ? "" : undefined}
    >
      {children}
    </div>
  );
}

This is where most people live. You see these APIs everywhere because it's way easier when you own all the state and all the elements in one place. But you lose composition.

What happens when we want to add a className to all, one, or just a few of the elements? You end up with weird APIs like:

<Listbox
  options={[
    { label: "Download", onSelect: download },
    { label: "Save", onSelect: save },
    { label: "Preview", onSelect: preview }
  ]}
  // stuff like this
  optionClassNames="cool"
  // or shoot, we need more than classNames
  optionsProps={{
    className: "cool",
    onMouseEnter: handler
  }}
  // dangit we need to do it differently depending on the option
  getOptionProps={(option, index) => {
    return index === 2 ? "bg-blue" : "bg-white";
  }}
  // ah forget it, here you do it, enjoy the branching!
  renderOption={(option, index) => (
    <MenuItem
      className={index === 2 ? "bg-blue" : "bg-white"}
      aria-label={index === 2 ? "Preview Invoice" : undefined}
    >
      {index === 0 ? (
        <DownloadIcon />
      ) : index === 1 ? (
        <SaveIcon />
      ) : index === 2 ? (
        <PreviewIcon />
      ) : null}
      {option.label}
    </MenuItem>
  )}
/>

Because the rendering is in the same owner as the state, we have to poke holes in the component to change anything about how it renders.

All that, just so the stinking MenuOption knows it's index in the parent's element tree.

Had we stuck to elements, we could have done this:

<Menu>
  <MenuItem className="bg-white" onSelect={download}>
    <DownloadIcon /> Download
  </MenuItem>
  <MenuItem className="bg-white" onSelect={save}>
    <SaveIcon /> Save
  </MenuItem>
  <MenuItem
    className="bg-white"
    onSelect={preview}
    aria-label="Preview Invoice"
  >
    <PreviewIcon /> Preview
  </MenuItem>
</Menu>

But how will the MenuItem's know their index?

Option 2: Type Checking and cloneElement

We can use cloneElement to keep (most of) the normal React composition. No more items prop. Instead we map the children, clone them, and pass them the state that we know in Menu.

function Menu({ children }) {
  const [activeIndex, setActiveIndex] = useState();
  return (
    <div data-menu aria-activedescendant={activeIndex}>
      {React.Children.map(children, (child, index) =>
        React.cloneElement(child, { index, activeIndex })
      )}
    </div>
  );
}

function MenuItem({ index, activeIndex, onSelect, children }) {
  // index came from the clone
  const isActive = index === activeIndex;
  return (
    <div id={index} data-active={isActive ? "" : undefined}>
      {children}
    </div>
  );
}

We've now seperated the state from the elements so that apps can compose however they please. If you want to put a className on one item and not another, you can, and we don't have to poke holes into our Menu component just to meet every use case that pops up.

Almost.

What if we need to put a div around one of the items?

<Menu>
  <div>
    <MenuItem />
  </div>
  <MenuItem />
</Menu>

This is totally broken now because we cloned the div not the MenuItem. You could recurse down the tree and type check until you find a MenuItem, but, come on.

A recursive type check could help a little, but it still limit composition, what if you wanted to do this?

function BlueItem(props) {
  return <MenuItem {...props} className="bg-blue" />;
}

<Menu>
  <MenuItem />
  <BlueItem />
</Menu>;

The type checking will fail 😭.

So now we need a way to define arbitrary components as a MenuItem. One workaround is a static property of the component to check instead of just type. The type checking changes from this element.type === MenuItem to this: element.type.is === MenuItem, and of course make sure apps assign BlueItem.is = MenuItem.

Option 3: Context Wrapper

To get around some of these issues we can create a context around each child and get some composition back:

const ItemContext = React.createContext();

function Menu({ children }) {
  const [activeIndex, setActiveIndex] = useState();
  return (
    <div data-menu aria-activedescendant={activeIndex}>
      {React.Children.map(children, (child, index) => (
        // instead of cloning, wrap in context
        <ItemContext.Provider value={{ index, activeIndex }}>
          {child}
        </ItemContext.Provider>
      ))}
    </div>
  );
}

function MenuItem({ onSelect, children }) {
  // state comes from context now
  const { index, activeIndex } = useContext(ItemContext);
  const isActive = index === activeIndex;
  return (
    <div id={index} data-active={isActive ? "" : undefined}>
      {children}
    </div>
  );
}

Now we don't need to type check and we can wrap a div around an Item or use a BlueItem because the values have been moved to context instead of directly cloning the element.

<Menu>
  <div>
    <MenuItem />
  </div>
  <BlueItem />
</Menu>

But we still have problems:

What if we want to seperate them into groups with arbitrary items inbetween?

<Menu>
  <MenuItem />
  <MenuItem />
  <hr />
  <MenuItem />
  <MenuItem />
</Menu>

Now we need to tell that third menu item that its index is not 3 (the 4th child of Menu) but rather that it's 2 (the third MenuItem). This also makes it difficult to manage the ArrowUp/ArrowDown keystrokes. Instead of just incrementing or decrementing the activeIndex you first have to figure out some way to find an array of only the children that are a MenuItem. So, you have to go back to type checking. Oh no! Now every child must be a MenuItem, so no more BlueItem or div wrappers.

Or, ofc, you can bail out of React completely and use DOM manipulation/traversal (not always a bad plan).

Even if we figured all that stuff out, remember <LocaleTimeZoneOptions/>? That rendered a fragment! So we'd end up wrapping multiple options in a single index. All four timezones would have index === 0, so they'd all focus together 😂. That's because the fragment is the child, and that's what we're wrapping in context. It would render this:

<Provider value={{ index: 0 }}>
  <>
    <MenuItem />
    <MenuItem />
    <MenuItem />
  </>
</Provider>

Oops.

Option 4: The Unholy Option

I goofed around to see if I could exploit useLayoutEffect and context to do a double render to get descendants to figure out their own index inside of that context.

And it worked... afaict.

function Menu() {
  // First you `useDescendants` to set up your array of items:
  const itemsRef = useDescendants(); // itemsRef.current === []

  // Next you render a provider
  return <DescendantProvider items={itemsRef}>{children}</DescendantProvider>;
}

function MenuItem({ onSelect }) {
  // Last, you register your descendant and get the index.
  // Usually you send a node ref up to the provider so you
  // can focus it from there, but you can send any value you
  // want, like our onSelect handler, and let the Menu call
  // it whenever it wants (like the Enter key)
  const index = useDescendant(onSelect);
}

For completeness, now we'll add in the activeIndex context as well.

const MenuContext = React.createContext();

function Menu() {
  const [activeIndex, setActiveIndex] = useState(-1);
  const itemsRef = useDescendants(); // itemsRef.current === []
  return (
    <MenuContext.Provider value={activeIndex}>
      <DescendantProvider items={itemsRef}>
        <div data-menu aria-activedescendant={activeIndex}>
          {children}
        </div>
      </DescendantProvider>
    </MenuContext.Provider>
  );
}

function MenuItem({ onSelect }) {
  const index = useDescendant(onSelect);
  const activeIndex = useContext(MenuContext);

  // now we know if we're active no matter what!
  const isActive = index === activeIndex;
  return (
    <div id={index} data-active={isActive ? "" : undefined}>
      {children}
    </div>
  );
}

Now managing focus and setting the activeDescendant is as easy as incrementing a value, and best of all, apps can do all the things just like a <select><option/></select>!

const el = (
  <Menu>
    <BlueItem />
    <CommonItems />
    <div>
      <MenuItem />
    </div>
  </Menu>
);

function CommonItems() {
  return (
    <>
      <MenuItem />
      <MenuItem />
      <MenuItem />
    </>
  );
}

Here's the code. If you dare 👻

import React, {
  createContext,
  useContext,
  useLayoutEffect,
  useEffect,
  useState,
  useRef
} from "react";

////////////////////////////////////////////////////////////////////////////////
// SUPER HACKS AHEAD: The React team will hate this enough to hopefully give us
// a way to know the index of a descendant given a parent (will help generate
// IDs for accessibility a long with the ability create maximally composable
// component abstractions).
//
// This is all to avoid cloneElement. If we can avoid cloneElement then people
// can have arbitrary markup around MenuItems.  This basically takes advantage
// of react's render lifecycles to let us "register" descendants to an
// ancestor, so that we can track all the descendants and manage focus on them,
// etc.  The super hacks here are for the child to know it's index as well, so
// that it can set attributes, match against state from above, etc.
const DescendantContext = createContext();

export function useDescendants() {
  return useRef([]);
}

export function DescendantProvider({ items, ...props }) {
  // On the first render we say we're "assigning", and the children will push
  // into the array when they show up in their own useLayoutEffect.
  const assigning = useRef(true);

  // since children are pushed into the array in useLayoutEffect of the child,
  // children can't read their index on first render.  So we need to cause a
  // second render so they can read their index.
  const [, forceUpdate] = useState();
  const updating = useRef();

  // parent useLayoutEffect is always last
  useLayoutEffect(() => {
    if (assigning.current) {
      // At this point all of the children have pushed into the array so we set
      // assigning to false and force an update. Since we're in
      // useLayoutEffect, we won't get a flash of rendered content, it will all
      // happen synchronously. And now that this is false, children won't push
      // into the array on the forceUpdate
      assigning.current = false;
      forceUpdate({});
    } else {
      // After the forceUpdate completes, we end up here and set assigning back
      // to true for the next update from the app
      assigning.current = true;
    }
    return () => {
      // this cleanup function runs right before the next render, so it's the
      // right time to empty out the array to be reassigned with whatever shows
      // up next render.
      if (assigning.current) {
        // we only want to empty out the array before the next render cycle if
        // it was NOT the result of our forceUpdate, so being guarded behind
        // assigning.current works
        items.current = [];
      }
    };
  });

  return <DescendantContext.Provider {...props} value={{ items, assigning }} />;
}

export function useDescendant(descendant) {
  const { assigning, items } = useContext(DescendantContext);
  const index = useRef(-1);

  useLayoutEffect(() => {
    if (assigning.current) {
      index.current = items.current.push(descendant) - 1;
    }
  });

  // first render its wrong, after a forceUpdate in parent useLayoutEffect it's
  // right, and its all synchronous so we don't get any flashing
  return index.current;
}

Limitations

It's a double render

Probably fine most of the time, but that's how it all works.

Server Rendering

Anything that needs to know the index (or whats active derived from that index) won't work for server rendering because we don't know that information until the second render pass. In the case of Tabs, the active panel wouldn't know its index on the first render pass, so the server would render nothing. Similarly, a Listbox button wouldn't know the label of the selected ListboxItem and be able to render it on the first pass, it would only know on the second pass.

Concurrent Mode

I'm pretty sure this won't work in concurrent mode if you split your items in different suspense boundaries:

<Menu>
  <CommonItems />
  <Suspense>
    <AsyncItems />
  </Suspense>
  <MenuItem />
</Menu>

Now the indexes would be all out of whack. I think when those AsyncItems render you'll lose the index on all the others, or get duplicates, I dunno.

Seems like bad UX to open a list, and then change some items (I hate that!), so I'm totally okay with this limitation for a user-space hack. As long as all the lists are rendered together, it'll work.

I Wish Something Like This Was Built-in

We talk about "react-call-return" in these conversations sometimes but it doesn't solve all the composition issues illustrated here (particularly it enforces a direct parent-child relationship, no arbitrary divs wrapping).

This feels like something that should be built into React. Seems like it already has to know the index of a child in a context, it has to render the tree, and find pieces to update. And it's fine if the value has to settle and changes often, we need this only during user interactions.

It could possibly be as simple as:

function MenuItem() {
  const index = useIndexForTypeIn(MenuContext)
}

And this would help us generate automatic IDs as well, a long standing request from people who work heavily in accessibility but want to keep ALL of the React composition model, not just some.

Andrew will say I'm asking for a faster horse with useIndexForTypeIn(MenuContext). I'll take a faster horse, or a car, or a rocket, I just need something besides throwing arrays into the top. What's the point of JSX if we don't want to compose elements together?

Thanks for coming to my TED talk.


Hi, I'm Ryan! I've been a webmaster since that was a thing. You might know me from React Router, Remix and Reach UI. You can follow me on Twitter, GitHub and YouTube.