When React parent components need to know their children
In React, it’s always preferable to maintain a linear data flow. If a parent needs something, lift the state instead of smuggling it back up. All rules are made to be broken, though.
Some (I think) valid use cases include:
- Building composible compound components, where the component tree is the source of data rather than passing it down via props.
- Managing
<head />tags. The parent can’t realistically know what the title or description of the page is, but most metdata tags can’t be rendered inline. - Overriding page layout, e.g. removing page chrome on a leaf route, when said chrome is provided by the parent.
Direct-child compound components
Compound components are the classic example of this:
<List>
<List.Item value="one">One</List.Item>
<List.Item value="two">Two</List.Item>
<List.Item value="three">Three</List.Item>
</List>TSX
What if List needs to know the number of items, or extract properties from children to build up state? We can easily map over the children and extract their props:
import React, { Children, PropsWithChildren, useMemo } from "react";
type ListItemProps = {
value: string;
};
function List({ children }: PropsWithChildren) {
const items = useMemo(
() =>
Children.toArray(children)
.filter(React.isValidElement<ListItemProps>)
.map((child) => ({
value: child.props.value,
})),
[children]
);
return (
<div>
<span>Total items: {items.length}</span>
<span>Last inserted: {items.at(-1)?.value}</span>
{children}
</div>
);
}TSX
Probably most of the time you want List to just own the data rather than do this, but sometimes this makes for a nicer, more composable component tree.
However, it completely falls down if you want to support arbitrarily nested children:
<List>
<ul>
<li><List.Item value="one">One</List.Item></li>
<li><List.Item value="two">Two</List.Item></li>
<li><List.Item value="three">Three</List.Item></li>
</ul>
</List>TSX
Which really reduces its usefulness as a composition primitive: the entire point of compound components is letting users control rendering.
Nested compound components
React ARIA/Spectrum has a very neat, if a bit wild, solution.
They provide a ‘collection’ component API that looks like this:
<Collection>
<Item>Open</Item>
<Item>Edit</Item>
<Item>Delete</Item>
</Collection>TSX
Their focus/state management (and more) requires knowledge of how many Item components there are, but each item might not be a direct child of a collection. Users may wish to wrap them for layout and/or styling.
They perform a first-pass render to a fake ‘document’ inside a portal:
It works by implementing a tiny version of the DOM with just the methods React needs (e.g.
createElement,appendChild, etc.). Then, it uses a React portal to render the collection into this fake DOM. React takes care of rendering all intermediary wrapper components, and leaf components like<Item>are rendered as “host” elements (similar to real DOM nodes). This gives us access to the underlying items as if they were rendered directly to the DOM, but without needing to pay this cost for large collections.
See said document, which implements the basic API required by react-dom.
This allows them to leverage React to render all the intermediaries while still getting an accurate picture of the component tree. Since the portal doesn’t use a real document.body, it works server-side too.
Managing <head /> tags
Another common example is managing <head /> tags. They need to be rendered at the top of the page (in an area not always managed by React), but each nested route will likely want to ‘own’ its own tags and data.
This is particularly awkward with SSR, since we can’t rely on hooks like useEffect alone to update data. For the tags example, we need to:
- Create state outside of React, per request.
- Pass that to a context provider.
- Provide a hook or a component that, when called:
- Immediately updates the shared state, merging in any changes.
- Updates on the client when changes are made.
- Compile the head tags from the shared state and make them available for building the final server-rendered HTML.
This is what React Helmet did, at least after it was fixed.
Today the library Unhead has taken its place:
import { useHead } from '@unhead/react';
function PageHead() {
// Head tags will update when title state changes
useHead({
title: 'My Page'
});
return <h1>My page</h1>;
}TSX
Under the hood, the hook gets the instance from context and immediately calls (inside a React state initialiser) a similarly named function that is not a hook:
export function useHead<T extends Unhead<any>, I = ResolvableHead>(unhead: T, input?: ResolvableHead, options: HeadEntryOptions = {}): ActiveHeadEntry<I> {
return unhead.push((input || {}) as I, options) as ActiveHeadEntry<I>
}TSX
This mutates the state, ensuring that on SSR the shared state is updated. A useEffect then keeps changes in sync.
Streaming also works, which is pretty neat: tags are dropped into <head /> as they stream in.
Matching against a known tree
Sometimes we can cheat, since we know up front what the tree will look like. This is true for route composition, and helps with issues like child routes overriding a parent’s styling, which is something that templating languages like Twig/Liquid made easy.
React Router’s useMatches hook is a great example of this. It returns all the matched routes, including nested ones. Since you can assign arbitrary metadata to routes via handle, you can set values in child routes and read them in the parent.
This makes composing breadcrumbs trivial:
// app/lib/handle.ts
export type Handle = { crumb?: string };TSX
// app/routes/settings.profile.tsx
import { Handle } from "~/lib/handle";
export const handle: Handle = {
crumb: "Profile",
};
export default function Profile() {
return <div>...</div>;
}TSX
// app/routes/settings.tsx
import { Outlet, useMatches, type UIMatch } from "react-router";
import { Handle } from "~/lib/handle";
export const handle: Handle = {
crumb: "Settings"
};
type CrumbMatch = UIMatch<unknown, Handle & { crumb: string }>;
function hasCrumb(match: UIMatch): match is CrumbMatch {
const handle = match.handle as Handle | undefined;
return typeof handle?.crumb === "string";
}
export default function SettingsLayout() {
const matches = useMatches();
const crumbs = matches
.filter(hasCrumb)
.map((m) => ({ id: m.id, pathname: m.pathname, label: m.handle.crumb }));
return (
<div>
<nav>
{crumbs.map((c) => (
<Link key={c.id} to={c.pathname}>{c.label}</Link>
))}
</nav>
<Outlet />
</div>
);
}TSX
useMatches even returns loader data, so you can always make crumb (or whatever the prop is) a function and pass dynamic data to it:
export type Handle = {
crumb: string | (data: { name: string }) => string,
};TSX
Don’t do this at home (maybe)
You should probably not do most of the above. You should likely just pass your data down.
But if you really do need to, then there are options.