Skip to content
LogoLogo

Plugin registry

The registry is create-slot's declarative channel — since 4.0, its only one. Plugins declare their contributions as data; resolvePlugins turns the plugin list into a Resolution — one pure, synchronous, deterministic function; hosts render what it resolved. A contribution is not an element that an effect moves at runtime. It is data plus a component, under a required id — so the whole graph is known before render, which is exactly what a server render needs.

The whole surface

// create-slot/core — React-free, importable from server components
defineSlot<Props>(name): Slot<Props>            // { name, contribute, override }
definePlugin(definition)                        // { id, contributes? } plus your own fields
resolvePlugins(plugins, options?): Resolution   // { slots, diagnostics }
entriesOf(resolution, slot): ResolvedEntry<Props>[]
 
// create-slot — the React adapter, plus everything above re-exported
<SlotProvider resolution onError? Failed? Pending? />
<SlotHost slot props? renderEntries?>placeholder</SlotHost>
useSlotProps(slot): Props | null
useContribution(): { slot, pluginId, contributionId }
<ContributionBoundary pluginId contributionId slot />

create-slot/core never imports React at runtime, so a server component, a Node script or a test can import it under any condition. The root entry re-exports the core, so a client module needs one import.

1. Name the slots

// slots.ts — pure data, importable from server modules too
import {  } from "create-slot/core"
 
export const  = <{ : string }>("nav-menu")
export const  = ("status-bar")

A slot is a descriptor — a name plus a props type, never a component. Since the core is React-free, slots.ts is importable from server modules too.

2. Declare the contributions

A plugin is an object with an id and a list of contributions. A contribution is an ordinary component that receives the host's props — declared under a required id.

// plugins/pricing.tsx
import {  } from "create-slot/core"
 
export const  = ({
  : "pricing",
  : [
    // "nav-item" is the contribution's id: required, unique inside the plugin.
    // The full id is "pricing/nav-item" — the React key and override target.
    .("nav-item", { : 10, :  }),
  ],
})
 
function ({  }: { : string }) {
  // An ordinary component: hooks, context, data fetching, all of it.
  if ( === "/checkout") {
    return null
  }
 
  return <>Pricing</>
}

A contribution's id is local to its plugin, never contains /, and must be unique inside it. The full id ${pluginId}/${contributionId} is the React key every host uses, the address disable and override target, and the name diagnostics use. Because the key is the id, inserting or removing a neighbouring contribution never remounts the others.

3. Resolve, and mount the provider

resolvePlugins is where everything that used to be render-time work happens: grouping, disable, override patches, sorting, key minting. The application owns the Resolution — and the memo boundary with it.

// app.tsx
import { , ,  } from "create-slot"
 
// One pure function turns the plugin list into a Resolution. Resolve at
// module scope or in a `useMemo` — the provider never rebuilds anything.
const  = ([])
 
export function ({  }: { : string }) {
  return (
    < ={}>
      <>
        < ={} ={{ :  }}>
          <>No plugins installed</>
        </>
      </>
    </>
  )
}

4. Configure without forking

disable drops a plugin or a single contribution by id. override patches one contribution's order or component. Typed overrides come from the slot, not from a string: a string id cannot carry Props, so NavMenu.override is where a replacement component is type-checked.

// The options bag: drop contributions by full id, patch them by full id.
const  = ({
  : "billing",
  : [.("beta-banner", { :  })],
})
 
function () {
  return <>Try the beta</>
}
 
const  = ([, ], {
  : { : ["billing/beta-banner"] },
  : [.("pricing/nav-item", { : 5 })],
})
 
// Problems come back as data — never thrown, never silently dropped.
export const  = .

Problems come back as diagnostics — never thrown, never silently dropped:

duplicate-plugin-id · duplicate-contribution-id · invalid-contribution-id
unknown-disable-target · unknown-override-target · override-slot-mismatch

In development the provider prints them once per content change; a production build pays nothing for the printing, and the data is still on the Resolution for a test to assert on:

expect(resolvePlugins(PLUGINS).diagnostics).toEqual([])

That one line is the catalog validator. The Resolution is plain data — slot name → sorted entries plus the diagnostics — so an inspector, a policy check or a snapshot is a .map over it, not a library feature.

5. Decide which plugins are enabled

The enabled set is application data — only the application knows whether the answer has to survive a hydration.

// enabling.tsx — the enabled set is application data. Filter the array, or
// keep the list whole and `disable` by id; both produce a new Resolution.
import type {  } from "create-slot/core"
import {  } from "react"
 
declare const : readonly []
declare function (): <string, boolean>
declare function (): .
 
export function () {
  const  = ()
 
  // One line. An inline `resolvePlugins()` per render is legal too — entries
  // are compared by content — but the memo spares the hosts as well.
  const  = (
    () =>
      (.(() => [.] !== false)),
    [],
  )
 
  return (
    < ={}>
      < />
    </>
  )
}

One rule comes with those inputs, and it is cheap to keep: give the resolver the same inputs, in the same order, on the server and on the client. See the SSR contract.

A suggested file layout

Nothing enforces this, and the library imports none of it. It is just the shape that keeps features from importing each other:

src/
  slots.ts            # defineSlot calls — the only module both sides import
  plugins/
    pricing/
      index.ts        # definePlugin: the manifest, a plain module
      nav-item.tsx    # the contribution components ("use client" under RSC)
    reporting/
      ...
  catalog.ts          # the array, in the order the server and client agree on
  app.tsx             # resolvePlugins, SlotProvider, and the hosts

A feature imports slots.ts and nothing else from the application. The application imports catalog.ts. Neither direction crosses between features. Under React Server Components this split is also the two-module discipline.

See it work

Enable a plugin and both hosts pick up the new Resolution at once. One card reads its own identity. The crash-test plugin throws on purpose, so the isolation boundary around every contribution is visible rather than described.

Enabled plugins

panels host

Pipeline

ACME-4417 · rendered as “pipeline/card”

Email

3 threads on ACME-4417

Per-contribution identity

useContribution is in the API because it is the one thing only the library knows while a contribution renders: which contribution it is — slot, plugin id, contribution id. Every per-plugin facility an application wants — a store, a prefixed logger, a settings namespace, a telemetry tag — hangs off it.

// The one thing only the library knows while a contribution renders: which
// contribution it is. Per-plugin stores, loggers and settings namespaces all
// hang off `useContribution().pluginId`.
function () {
  const {  } = ()
  const  = ()
 
  return <>{.}</>
}
 
export const  = ({
  : "reporting",
  : [
    .("card", { : 10, :  }),
  ],
})

What you give up

A plugin's contributions do not share a React subtree, so they do not share ordinary useState. Two contributions of one plugin are two separate places in the tree that happen to carry the same pluginId.

That is the real cost of the declarative channel, and it has a straightforward answer: a store, keyed per plugin. See Plugin state for redux and mobx, both assembled from the catalog before render.

What it does not do, on purpose

No when predicate. Visibility has exactly one mechanism: the contribution returns null. The cost is that a host cannot know how many contributions produced output — its children render when nothing is contributed, which is not the same thing. See empty states.

No policies beyond disable. Limits, allowlists and caps over the resolved graph are a .filter over Resolution.slots the application writes with its own vocabulary.

No exclusive slots and no routing. "Exactly one owner" is a routing problem, not a slot problem. Keep the claim in the manifest as an application field and resolve it into one table before render, where your code knows the actual keys and can report the plugin it refused.

No inventory helper. The inventory exists because the manifest is data: it is a .map.

No state, no lifecycle, no command registry. The library knows who contributes what, in what order, and what happens when it breaks. Everything else is yours, and the manifest is open data you can extend with your own fields.

Next