API
Two entry points. create-slot/core is React-free — manifests, descriptors and
the resolver — importable from server components, Node scripts and tests. The
root entry is the React adapter, and it re-exports the core, so a client module
needs one import. Hover any identifier below — the types come from the
published package, not from prose.
import {
ContributionBoundary,
createSlot,
definePlugin,
defineSlot,
entriesOf,
resolvePlugins,
SlotHost,
SlotProvider,
useContribution,
useSlotProps,
} from "create-slot"| Export | Entry | What it is |
|---|---|---|
defineSlot | core | Creates a slot descriptor: name, contribute, override. |
definePlugin | core | Identity plus an id check. Your own fields keep their types. |
resolvePlugins | core | The whole registry as one pure function: plugins in, Resolution out. |
entriesOf | core | The entries of one slot, typed by its descriptor. |
SlotProvider | adapter | Puts one Resolution in scope for every host below it. |
SlotHost | adapter | Renders every contribution to a slot, in resolved order. |
useSlotProps | adapter | The nearest host's props for a slot. |
useContribution | adapter | Which contribution is rendering: slot, plugin id, contribution id. |
ContributionBoundary | adapter | The per-contribution isolation wrapper, for hand-rolled hosts. |
createSlot | façade | The runtime channel, whole and alone — SPA-only, source-compatible with 2.x/3.x. |
Everything that can throw is listed on one page, with its cause and its fix — see Errors.
defineSlot
const NavMenu = <{ : string }>("nav-menu")
A slot is a descriptor — pure data, safe to import from server modules. Type
safety lives on this object, because a string id cannot carry Props.
The name must be non-empty: the Resolution keys contributions by name, so an empty one is two slots quietly sharing a bucket.
slot.contribute(id, spec)
// Data plus a component, under a required id. The full id "pricing/nav-item"
// is the React key, the disable/override address, and the diagnostics name.
const = .("nav-item", {
: 10,
: ,
})| argument | type | notes |
|---|---|---|
id | string | Required. Local to the plugin, never contains /, unique inside it. The full id ${pluginId}/${id} is the React key and the override address. |
spec.component | ComponentType<Props> | Receives the host's props as its own. May accept fewer; never different ones. |
spec.order | number | Defaults to 0. A priority, not an array index — see Ordering. |
The return value is plain data — { slot, id, order, component } — which is
what lets the resolver enumerate it before render. A malformed id is reported
by the resolver as a diagnostic, not thrown at declaration.
slot.override(target, patch)
A typed patch aimed at one full contribution id. It comes from the slot rather
than a string because this is where a replacement component is checked
against the slot's Props.
// The application re-ranks a contribution it does not own by its full id.
// Typed patches come from the slot: `override` is where a replacement
// component would be checked against the slot's props.
export const = .("export-csv/import", { : 5 })definePlugin
Identity plus a non-empty-id check — and the one place where the plugin system
is discoverable in a codebase. It is generic over its argument, so your own
fields keep their types.
export const = ({
: "pricing",
: [],
// Any further field is yours. The library reads `id` and `contributes` only.
: "Pricing",
})
// Inference keeps your own fields.
export const label = .
The library reads id and contributes. Everything else on the object is
yours; see Plugin state.
resolvePlugins
The whole registry as one pure, synchronous, deterministic function: grouping,
disable, override patches, sorting (order, then plugin position, then
declaration position), key minting. Inputs are never mutated. The same plugins
and options produce a deep-equal Resolution every time — which is the entire
SSR contract.
// One pure, synchronous, deterministic function. Problems come back on
// `diagnostics` — never thrown, never silently dropped.
const = ([], {
: { : [] },
: [.("pricing/nav-item", { : 5 })],
})
export const = .| option | type | notes |
|---|---|---|
disable.plugins | readonly string[] | Plugin ids to drop whole. |
disable.contributions | readonly string[] | Full contribution ids to drop one by one. |
overrides | readonly Override[] | Typed patches from slot.override(). Later patches to one target win. |
Problems come back on resolution.diagnostics — never thrown, never silently
dropped. Six codes: 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; in a test,
expect(resolvePlugins(PLUGINS).diagnostics).toEqual([]) is the catalog
validator.
entriesOf
// The entries of one slot, typed by its descriptor — what a hand-rolled or
// server host maps over.
export const navEntries = (, )
Restores what contribute erased: each entry's component is typed by the
slot, no cast. This is what a hand-rolled host — including a
server host — maps over.
SlotProvider
function ({ , }: & { : () => void }) {
return (
< ="button" ={}>
Retry {}
</>
)
}
export function ({ }: { : string }) {
return (
<
={}
={}
={}
>
<>
< ={} ={{ : }}>
<>No plugins installed</>
</>
</>
</>
)
}| prop | type | notes |
|---|---|---|
resolution | Resolution | The pre-resolved graph. Identity-compared: hold it at module scope or in useMemo. |
onError | (error: SlotError) => void | Reported when a contribution throws. Inline arrows are free. |
Failed | ComponentType<SlotError & { reset }> | Rendered in place of a contribution that threw; reset retries it. |
Pending | ComponentType<ContributionInfo> | Rendered while a deferred contribution loads. Unset keeps null. |
Failed and Pending are components, not render props: a component
reference has stable identity and crosses an RSC boundary, where a closure
cannot. The handlers live in a context of their own, read only where a
contribution is isolated, so inline values never re-render a host.
SlotHost
Renders every contribution to a slot, in resolved order. Its own children are the placeholder while nothing is contributed.
| prop | type | notes |
|---|---|---|
slot | Slot<Props> | Which slot to render. |
props | Props | An explicit bag, not a spread — the host's own props can never collide with a slot's, and children structurally cannot leak into a contribution. Optional when Props is empty. |
children | ReactNode | The placeholder. Renders only while nothing is contributed. |
renderEntries | (entries: readonly HostEntry[]) => ReactNode | Full-ownership escape hatch — see below. |
Host props are value-held: the host keeps them stable while their values stay
the same, and it compares each resolved entry by content, so a host that
re-renders for someone else's sake does not re-render the contributions. A
host outside a SlotProvider
throws.
renderEntries
Called with every entry — with zero too — and owns layout, wrappers and the
empty state; children is then ignored. Each HostEntry carries the identity
(key, pluginId, contributionId, order) plus node, the contribution
already wrapped in memo, error boundary, Suspense and identity. Key your own
wrappers with entry.key, never with an array index.
// Full-ownership escape hatch: `renderEntries` owns layout, wrappers and the
// empty state. Key wrappers with `entry.key`, never with an array index.
export function ({ }: { : string }) {
return (
<
={}
={{ : }}
={() =>
. === 0 ? (
<>Nothing contributed</>
) : (
<>{.(() => .)}</>
)
}
/>
)
}useSlotProps
// The nearest host's props for this slot; null outside one.
export function () {
const = ()
return ? <>{.}</> : null
}Returns Props | null — null outside a host. A declared contribution rarely
needs it: the host's props arrive as its own. It exists for components nested
deeper inside a contribution, and it is context, so it is client-only — a
server host passes serializable
props directly.
useContribution
export function () {
// The identity of the contribution rendering now. Throws outside one.
const { , , } = ()
return (
< ={} ={} ={} />
)
}Throws outside a contribution. Use it to give each plugin a store, a prefixed logger, a settings namespace, or a telemetry tag.
ContributionBoundary
The per-contribution isolation wrapper the default host places around every
entry: contribution identity, an error boundary, and a Suspense boundary.
Exported so hand-rolled hosts — including RSC server components mapping a
Resolution — keep the same failure semantics as the default host.
// The isolation wrapper the default host places around every contribution —
// exported so a hand-rolled host keeps the same failure semantics.
export function () {
return (, ).(() => {
const = .
return (
<
={.}
={.}
={.}
={.}
>
< ="/" />
</>
)
})
}createSlot
The runtime channel, whole and alone — SPA-only by design: the server and hydration snapshots are always empty. Each factory owns a private store, shared with nothing; the registry never merges it.
const Menu = <{ : string }>()
<Slot order?>
The component itself is the contributor. It renders nothing where it sits and takes exactly one element as its child.
// The component itself is the contributor. `children` is one element.
export function () {
return (
< ={10}>
< />
</>
)
}| prop | type | notes |
|---|---|---|
children | ReactElement | Required. Exactly one element — not text, not siblings. |
order | number | Defaults to 0. Read once, on mount. |
Both are also the React key's source: the key is assigned on mount and kept, so changing the child's content reconciles instead of remounting.
<Slot.Host {...props}>
// The host renders every mounted fill, or its own children while there are none.
export function ({ }: { : string }) {
return (
<>
<. ={}>
<>Placeholder</>
</.>
</>
)
}Unlike SlotHost, the façade host spreads its props — the 2.x shape — and
tolerates rendering with no provider anywhere, because the façade has none.
Slot.useProps()
function () {
// The props of the host doing the rendering. Never null: a fill's children
// only ever render inside a host.
const { } = .()
return <>{}</>
}Unlike useSlotProps, this one is not nullable: a Slot's children only ever
render inside one of its factory's hosts, so the façade can promise the props.
Types
All exported from the root entry; the core types also from create-slot/core.
| Type | What it describes |
|---|---|
Slot<Props> | What defineSlot returns: name, contribute, override. Pure data. |
ContributionSpec<Props> | The second argument to contribute: { component, order? }. |
Contribution | What contribute returns: { slot, id, order, component }. Plain data. |
Override | What slot.override returns. Opaque data; resolvePlugins consumes it. |
PluginDefinition | The two fields the library reads: id, contributes?. |
ResolveOptions | The options bag: disable?, overrides?. |
Resolution | The resolved graph: slots (name → sorted entries) plus diagnostics. |
ResolvedEntry<Props> | One entry: key, pluginId, contributionId, slot, order, seq, component. |
Diagnostic | A problem the resolver found. Returned, never thrown. |
ErasedComponent | A contribution's component with its props erased, so one graph holds every slot's. |
SlotProviderProps | resolution, onError?, Failed?, Pending?, children?. |
SlotHostProps<Props> | slot, props, children?, renderEntries?. |
HostEntry | What renderEntries receives: identity plus the finished node. |
SlotError | { pluginId, contributionId, slot, error } — what onError and Failed receive. |
ContributionInfo | { slot, pluginId, contributionId } — what useContribution returns and Pending receives. |
RuntimeSlot<Props> | What createSlot returns: the contributor, plus Host and useProps. Named Slot<Props> before 4.0. |
PluginDefinition is the one to extend. definePlugin is generic over its
argument, so an intersection type keeps every field of your own:
// `PluginDefinition` is the type to extend. `definePlugin` is generic over its
// argument, so an intersection keeps every field of your own.
type = & {
: string
?: readonly string[]
}
const = ({
: "billing",
: "Billing",
: ["billing:read"],
}) satisfies
export const billingPermissions = .
Behavioural notes
- A host with no contributions renders its own children.
- Multiple hosts of one slot each render the same contributions;
useSlotPropsreflects the host doing the rendering. orderis a priority. Equal values are a stable tie: plugin position in the list, then declaration position. A façade fill reads itsorderonce, on mount.- Entries are compared by content, because
resolvePluginsmints fresh entry objects on every call and an inline call per render is legal. A host holds its props steady while their values are unchanged. See Performance. - Each
createSlot()factory owns a private store: two factories can never exchange fills, two React roots using one factory always do, and no module-level registry exists for a duplicated package copy to split.