Skip to content
LogoLogo

Two channels

A contribution reaches a host through one of two channels, and the two differ in what a contribution is. Everything else — whether it appears in server HTML, whether one feature's parts can share useState, whether an error boundary wraps it — follows from that.

Since 4.0 the two channels are two APIs with two hosts. The registry is the declarative channel, whole; the runtime channel lives entirely inside the createSlot() façade. The registry never merges fills.

The short answer

The registry for anything that must be in the HTML. The façade for chrome that depends on live tree state.

If one question decides it, it is this: does the contribution have to exist before hydration? Navigation, above-the-fold content, anything a crawler reads, anything whose late arrival shifts the layout — the registry. A status entry that exists only while a dialog is open, a badge a row puts up about its own selection — the façade.

When neither applies, prefer the registry. It is the primary channel, it costs nothing extra, and it is the one that keeps its options open.

The rest of this page is why.

Declarative: data plus a component

slot.contribute(id, spec) produces a plain object: a slot name, a required id, an order, and a component. A plugin carries a list of them, and resolvePlugins turns the plugin list into a Resolution before render.

// Declarative: the contribution is data plus a component, under a required
// id. The resolver enumerates it before render — which is exactly what a
// server render needs.
export const  = ({
  : "export",
  : [
    .("export-button", { : 10, :  }),
  ],
})
 
function ({  }: { : string }) {
  return < ="button">Export {}</>
}

Because the graph is resolved before render, a host enumerates its contributions synchronously — which is exactly what a server render needs, and what lets the whole Resolution cross an RSC boundary. This is the primary channel.

Runtime: an element from where it is mounted

A createSlot() factory's slot component renders nothing where it sits. It registers its child element into the factory's private store for as long as it is mounted, and every mounted host of that factory renders it.

// Runtime: the fill is an element, registered from wherever it is mounted for
// as long as it is mounted. It lives entirely inside the createSlot() façade
// and never reaches server markup.
import {  } from "create-slot"
 
const  = ()
 
export function ({  }: { : boolean }) {
  if (!) {
    return null
  }
 
  return (
    < ={20}>
      <>Unsaved changes</>
    </>
  )
}

This channel cannot reach server markup. Its server and first hydration snapshots are both empty: the client cannot reproduce fills from subtrees it has not reached yet without a mismatch. Registration therefore happens in an effect, after hydration.

The comparison

Registry (declarative)Façade (runtime)
A contribution isdata plus a component, under an idan element
Registeredbefore render, by resolvePluginsfrom an effect, while mounted
In server HTMLyesno
HostSlotHost, under SlotProviderthe factory's own Host, no provider
Addressableby full id: disable, override, diagnosticsno identity
Isolation per contributionidentity + error boundary + SuspenseSuspense with a null fallback
One feature's parts share useStateno — separate subtreesyes — one subtree
Reads host props viaits own propsuseProps()
Turning a feature offresolve without it, or disable by idstop rendering it

The façade's store belongs to the factory, not to a module-level registry: two factories can never exchange fills, and two React roots using one factory always do.

What each choice costs

Reach for the registry when the contribution is navigation, above-the-fold content, anything a crawler reads, or anything whose absence during hydration would shift the layout. You get server rendering, streaming, addressable ids and per-contribution failure isolation, and you pay for it by giving up the shared React subtree: a plugin's contributions no longer share ordinary useState, so shared state needs a store. In exchange you get per-contribution memoisation — see Performance.

Reach for the façade when the contribution genuinely is not known up front — a status bar entry that exists only while a dialog is open, a badge a row puts up about its own selection, chrome that depends on where in the tree it happens to be mounted. A feature stays one subtree, so its parts share ordinary state, and installing it is mounting it. You pay for it with SSR, identity and isolation — it is the application's own code in the application's own tree.

Using both

An application uses both channels side by side — one registry for the content, a façade slot here and there for the live chrome. What no longer exists is a mixed host: a registry host renders declared contributions only, and a façade host renders its factory's fills only. A late client fill can therefore never displace markup the server already shipped — that class of hydration bug is unrepresentable by construction.

// The registry never merges runtime fills. Content that must be in the HTML
// is declared; live chrome gets a façade slot of its own, with its own host.
import {  } from "create-slot"
 
const  = ()
 
export function ({  }: { : string }) {
  return (
    < ={}>
      <>
        < ={} ={{ :  }} />
      </>
      <>
        <.>
          <>Idle</>
        </.>
      </>
      {/* Registered while mounted. Never part of the Resolution. */}
      < ={10}>
        <>Unsaved changes</>
      </>
    </>
  )
}

The pages-router example ships exactly this shape: every feature surface server-rendered from the Resolution, and one façade status bar that fills after hydration.

Next