The challenge
Building a small app, I wanted to use this page template from TailwindUI:
I used their provided markup as an AppLayout
layout, and replaced the inner dashed div with the output of {children}
.
That works well, but there’s one issue: the “Dashboard” title hovering over the page content is living in another part of the DOM.
This is what it looks like:
...
<div>Page title is here</div>
<div>
<div> <!-- This is the content wrapping div for white background and rounded corners -->
{children}
</div>
</div>
...
So how do I get the page’s title up there, when its entire content is rendered with {children}
in a completely different place?
What if I want to have a title+subtitle header over my page content, or a page-specific menu injected into some other part of the layout?
The challenge here is to have the child component define what should be rendered within multiple locations in its parent.
Here’s how I solved it
A solution
As I approached this, I identified 3 steps I needed to clear:
- Render a component (page title) into an arbitrary container
- Communicate a container from the layout to it’s current page
- Make it as easy to use as possible
1. Render outside of “flow”
The canonical solution for this is to use Portals. Portals are specifically made to render a component tree into a given DOM container, wherever it might be, so that’s a perfect way to clear our first step.
A usual component would render its tree this way:
function MyComponent(props) {
return <div>Component content</div>
}
To use Portals, we have to return a Portal instance, created with its children (DOM tree to render) and the container they should be rendered into:
import { createPortal } from 'react-dom'
function MyComponent(props) {
return createPortal(<div>Component content</div>, props.domContainer)
}
You would use MyComponent
as usual, but instead of the inner <div>
being rendered in place of the <MyComponent>
tag, it would instead end up inside the domContainer
DOM Element passed through the props.
2. Passing down the container DOM Element
Portals look like a great solution for step #1, but the fact that createPortal
requires a DOM Element to render into poses a new constraint: we need a reference to the layout’s container that will be used as placeholder for the extra content coming from the page.
Getting a reference to the DOM Element
Getting a reference to a DOM Element with React is usually done with a… reference
That’s the first step:
import { useRef } from 'react'
function AppLayout() {
const pageTitleRef = useRef()
return (
<>
<div ref={pageTitleRef}>Page title is here</div>
<div>
<div> <!-- This is the content wrapping div for white background and rounded corners -->
{children}
</div>
</div>
</>
)
}
pageTitleRef.current
will now point to the DOM Element that’s supposed to hold our page’s title.
However, what we don’t have is a reference to the Page component that we need to render. At least not directly, since it’s “inside” the children
prop, but actually wrapped with multiple layers of internal Redwood components.
Passing the reference down
We’ll make use of another React mechanism: Contexts.
Since we can’t pass our reference as a prop, a Context is a good candidate:
Context provides a way to share values […] between components without having to explicitly pass a prop through every level of the tree.
Here’s the gist of the Context mechanism:
- A Context is created using
React.createContext
. It holds a value, which can be a scalar value as much as an object. - A Context Provider is used to wrap a component tree and provide a specific value for that context.
- The components in the tree can get access to the value the Provider set by using a Context Consumer
In our case, we can create a LayoutContext
that AppLayout
will provide to its descendants. The context will contain references to the placeholders the layout provides, so that the current page component can “consume” them and render into them through portals.
Concretely:
// AppLayout.js
import { createContext, useRef } from 'react'
export const LayoutContext = createContext({ pageTitle: null })
function AppLayout() {
const pageTitleRef = useRef()
return (
//
// The page title reference is provided as context value
<LayoutContext.Provider value={{pageTitle: pageTitleRef}}>
<div ref={pageTitleRef}>Page title is here</div>
<div>
<div>
{children}
</div>
</div>
</LayoutContext.Provider>
)
}
LayoutContext.Provider
wraps the layout DOM and thus the rendered Page component. It provides a new value to the context, passing the page title container reference we’ve just set up.
Now the Page component rendered within {children}
can make use of that context:
// MyPage.js
import { LayoutContext } from 'src/layouts/AppLayout/AppLayout'
import { createPortal } from 'react-dom'
export default function MyPage() {
return (
<>
{* Consume context to create a portal to the layout's placeholder *}
<LayoutContext.Consumer>
{(context) => createPortal(<h1>My page title</h1>, context.pageTitle.current)}
</LayoutContext.Consumer>
{* Below, the actual page content *}
...
</>
)
}
The Consumer
component calls the child function with the context value as parameter, and will render the components returned from the function. But here, instead of just returning some components, we use createPortal
to ask React to effectively render our page title into the layout’s title placeholder, using the reference to it that’s been passed down in the context.
The added benefit of using portals is that we’re not even constrained to string content: any React content is supported.
Mission accomplished!
3. A better DX
Developer experience is always important.
You’re most likely to use a tool if its API is pleasant to use, even if it’s of your own making.
As @realStandal suggested on Discord when I was researching this problem (emphasis mine):
Then if I were doing it, I’d make a component that takes a string as its
children
prop and use that to update the title from each Page, so I don’t have to worry about consuming/invoking a function to update that context’s value on each page - I can just plop the component down next to<Meta>
or in-general: before the page’s content
Let’s do exactly that.
When working on an “API”, I like to start with what I want the user-side to look like and work from that:
// MyPage.js
import { Slot } from 'src/components/LayoutSlot/LayoutSlot'
export default function MyPage() {
return (
<>
<Slot name="pageTitle">
<h1>My page title</h1>
</Slot>
{* Below, the actual page content *}
...
</>
)
}
The Slot
component takes a name
property that would match the reference key in the LayoutContext
value. That makes the component generic so that it can be used for any layout placeholder, but still easy to use.
Let’s reproduce the context consumer shenanigans within that new component:
// LayoutSlot.js
import { LayoutContext } from 'src/layouts/AppLayout/AppLayout'
import { createPortal } from 'react-dom'
export function Slot({ name, children }) {
return (
<>
<LayoutContentContext.Consumer>
{(context) =>
createPortal(children, context[name].current)
}
</LayoutContentContext.Consumer>
</>
)
}
And that’s it really!
We now have a mechanism to create placeholders/slots in a page Layout, and provide content for them from lower levels at the Page level (or below)