How To: Populate multiple content placeholders in a Layout from the displayed Page

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 :slight_smile:

A solution

As I approached this, I identified 3 steps I needed to clear:

  1. Render a component (page title) into an arbitrary container
  2. Communicate a container from the layout to it’s current page
  3. 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 :slight_smile:

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! :+1:

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) :partying_face:

6 Likes

@olance Amazing solution, and even more amazing write up!

Wrapping it all up in the Slot component at the end felt really nice, especially for a component that uses Portals—that implementation detail feels almost completely hidden.

This is definitely a problem that everyone’s going to need to solve at some point if they want to use layouts correctly, so I’m wondering if we should try to incorporate this at the framework-level somehow?

The simplest solution might be to just generate layouts with all the parts you enumerated already hooked up—the context, a ref to something, and the Slot component. Right now the layout template generators are a little empty and could use something like this:

But I’m sure there’s other ways we could do it. Thanks for kicking off the discussion!

3 Likes

Amazing solution, and even more amazing write up!

Hey Dom! Thank you, I’m glad you like them both :grin:

I was thinking the same thing: it’s probably something that could be useful to anyone, if it was correctly integrated into the framework :slight_smile:

It’s not 100% there yet though in my opinion: I think to be really dev-friendly and framework-worthy, we should find something more solid on the slots declaration side: I’m sure handling all the useRef and the context values manually isn’t the best we can achieve!

There might be something to do with a useSlot hook?

Or even better, to mimick Vue’s mechanism: <Slot name="mySlot"> would be a component used within the layout that would populate the context automatically, and <Template for="mySlot"> the component that I called Slot in my write up above.
Slot wouldn’t even be restricted to a layout: as long as it’s used within a context, nested/sibling components could include a Template for it!

I’ll try to think about it :slight_smile:

Sooo, I thought about it :grimacing:

More seriously, just a quick thoughts update:

  1. this is definitely Redwood agnostic, so I thought I might as well make it a lib first and then a PR to integrate it
  2. I looked for existing libs providing the same functionality, and there are lots! I have found 2 that seem very close/identical to what I described above, and fairly maintained (last commits a few months old): react-view-slot // slotty

I’m all for not reinventing the wheel, so maybe we should just evaluate those libs and integrate one of them if they fit the bill? If none does, we can then work on improving what I’ve started.

What do you think?

Also, do we have an official “buy vs. build” position at Redwood? ^^

Nice to see you fleshed this out! :grin:

Maybe throw up a quick project that uses your solution and the two others, see which has the more all-around pleasing implementation? (subjective, ik ik). Can’t imagine the build-size would suffer too much regardless of which is used.

From the technologies section of Redwood’s introduction:

“Where existing libraries elegantly solve our problems, we use them; where they don’t, we write our own solutions.”

Tying in my suggestion above, the “elegant” sticks out to me and what it would entail.

If there are quality third party libs we definitely don’t mind using them. See pino for logging and react-hook-form for our forms for example :slight_smile:

1 Like

Yes I guess I’ll do that :slight_smile:

Thank you, and @Tobbe, for chiming in! ^^

Hey! Did you ever get around to doing this?

I came up with an alternative way of achieving this that I found easier to integrate. For whatever reason, I kept getting errors when using the useRef approach due to the initial ref value being null.

Instead, I created a zustand store and component that sets/resets the store accordingly:

// components/PageTitle/PageTitle.tsx
import create from 'zustand'

type PageTitleState = {
  pageTitle: string
}

type PageTitleAction = {
  setPageTitle: (pageTitle: string) => void
}

export const usePageTitle = create<PageTitleState & PageTitleAction>((set) => ({
  pageTitle: '',
  setPageTitle: (pageTitle) => set({ pageTitle }),
}))

const PageTitle = ({ children }: { children: string }) => {
  const setPageTitle = usePageTitle((state) => state.setPageTitle)
  useEffect(() => {
    setPageTitle(children)
    return () => setPageTitle('')
  }, [children, setPageTitle])

  return <></>
}

Then, I can use this component in any page like this:

// pages/AssetsPage/AssetsPage.tsx
import { Link, routes } from '@redwoodjs/router'
import { MetaTags } from '@redwoodjs/web'

import PageTitle from 'src/components/PageTitle/PageTitle'

const AssetsPage = () => {
  return (
    <>
      <PageTitle>Assets</PageTitle>
      <MetaTags title="Assets" description="Assets page" />
      <p>
        Find me in <code>./web/src/pages/AssetsPage/AssetsPage.tsx</code>
      </p>
      <p>
        My default route is named <code>assets</code>, link to me with `
        <Link to={routes.assets()}>Assets</Link>`
      </p>
    </>
  )
}

export default AssetsPage

Finally, in my layout, I can just grab the title of the page using usePageTitle and know that it will be reset on navigation due to the function returned to useEffect:

// layouts/AppLayout/AppLayout.tsx
import { usePageTitle } from 'src/components/PageTitle/PageTitle'

type AppLayoutProps = {
  children?: React.ReactNode
}

const AppLayout = ({ children }: AppLayoutProps) => {
  const pageTitle = usePageTitle((state) => state.pageTitle)
  return (
    <>
      <h1>{pageTitle}</h1>
      {children}
    <>
  )
}

1 Like