Skip to main content

Command Palette

Search for a command to run...

React Slots: Dynamic Sidebars and Headers Without the Mess

Published
3 min read

The Problem

You have a layout with a sidebar. Different pages need different sidebar content. The obvious solutions all have tradeoffs:

Prop drilling: Pass sidebar content up through every layer. Verbose, brittle.

Global state: Store sidebar config in Redux/Zustand. Now your UI is in a store.

Render props: <Layout sidebar={<Filters />}>. Works until you need sidebar content that depends on state defined inside the page.

That last point is the killer. Your filters component needs access to state that lives deep in your page component. Now what?

The Solution

A slot is a named portal target discovered at runtime via React context.

import { createContext, useContext, useState, PropsWithChildren } from 'react'
import { createPortal } from 'react-dom'

type SlotState = [
  HTMLDivElement | null,
  React.Dispatch<React.SetStateAction<HTMLDivElement | null>>,
]

export function createSlot(name: string) {
  const Context = createContext<SlotState | null>(null)

  function useSlot() {
    const ctx = useContext(Context)
    if (!ctx) {
      throw new Error(`${name} must be used within ${name}.Provider`)
    }
    return ctx
  }

  function Provider({ children }: PropsWithChildren) {
    const slotState = useState<HTMLDivElement | null>(null)
    return <Context.Provider value={slotState}>{children}</Context.Provider>
  }

  function Slot(props: React.HTMLAttributes<HTMLDivElement>) {
    const [, setSlot] = useSlot()
    return <div {...props} ref={setSlot} />
  }

  function Portal({ children }: PropsWithChildren) {
    const [slot] = useSlot()
    return slot ? createPortal(children, slot) : null
  }

  return { Provider, Slot, Portal }
}

Usage

const Sidebar = createSlot('Sidebar')

function Layout({ children }: PropsWithChildren) {
  return (
    <Sidebar.Provider>
      <div className="flex">
        <aside>
          <Sidebar.Slot />
        </aside>
        <main>{children}</main>
      </div>
    </Sidebar.Provider>
  )
}

function SearchPage() {
  const [filters, setFilters] = useState({ date: 'any', type: 'all' })
  const results = useSearch(filters)

  return (
    <>
      <ResultsList results={results} />

      <Sidebar.Portal>
        <Filters value={filters} onChange={setFilters} />
        <ResultCount count={results.length} />
      </Sidebar.Portal>
    </>
  )
}

The filters live in the sidebar visually, but in SearchPage logically. State stays colocated with the component that uses it.

Why This Works

React has two hierarchies: the component tree (data flow) and the DOM tree (visual layout). They're usually the same. Portals let you separate them.

Standard portals target fixed DOM nodes like document.body. This pattern targets a dynamic node discovered via context. The slot registers itself with a ref. The portal finds it through the same context.

Result: components inject content into arbitrary layout positions while preserving normal React data flow. Context, state, handlers — everything works as expected.

When to Use This

  • Sidebars with page-specific content

  • Header actions that depend on page state

  • Toolbars that change based on selection

  • Any layout where content source and content location should be decoupled

When Not To

If your sidebar content doesn't need state from inside the page, simpler patterns work fine. Render props or composition handle static content without the indirection.