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.
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?
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
.
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.
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;
}
Probably fine most of the time, but that's how it all works.
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.
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.
We talk about "react-call-return" in these conversations some times 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.
👏 Reading this was like watching myself run in to these walls 1000x all over again 😂