Declarative slot system for React. Declare named extension points (slots) in your components and inject content into them from anywhere in the codebase — plugins, separate packages, lazy-loaded modules — without editing the component that declares the slot.
- Repository: https://github.com/grlt-hub/react-slots
- npm: https://www.npmjs.com/package/@grlt-hub/react-slots
- License: MIT
- Current major: v5. The v3 API (
createSlots,createSlotIdentifier,when, effector peer dependencies) is gone; v5 has a single factorycreateSlotand no effector.
npm i @grlt-hub/react-slots@5
# or
pnpm add @grlt-hub/react-slots@5
# or
bun add @grlt-hub/react-slots@5
# or
yarn add @grlt-hub/react-slots@5Until 5.0.0 reaches the latest tag, pre-releases are published under the beta tag: npm i @grlt-hub/react-slots@beta.
- Runtime is ~2 KB gzipped. The only dependency is
use-sync-external-store(shim), so React 16.14 – 19 are all supported:peerDependencies: react >=16.14.0 <20.0.0. - TypeScript types ship with the package; minimum supported TypeScript for consumers is 6.0. The types rely on chained inference between object-literal members (
filternarrows →mapPropsreceives the narrowed type and shapes →Componentprops, TS 4.7+), the built-inNoInferintrinsic (TS 5.4+), and inferred type predicates for annotation-freefilternarrowing (TS 5.5+). - Published as ESM + CJS with correct
exports; bundles carry a"use client"directive (safe to import from React Server Components projects —Rootand hooks are client-side). - SSR-safe: rendering
Rooton the server works; presence hooks return0/ all-falseon the server and correct themselves after hydration. - Sole export:
createSlot.
A slot is created at module level, outside React:
import { createSlot } from "@grlt-hub/react-slots"
const widgets = createSlot()createSlot() returns:
Root— a React component. Place it where injected content should appear. It renders all inserted components, in order.api.insert(options)— inject a component into the slot. Callable from anywhere (plugin entry points, module top level, effects), before or afterRootmounts. Every insert triggers a re-render of mountedRoots.api.clear()— remove all inserted components.
// host component — knows nothing about what fills the slot
const Sidebar = () => (
<aside>
<nav>Core navigation</nav>
<widgets.Root />
</aside>
)
// plugin-analytics/index.ts — separate package, injects from outside
widgets.api.insert({ Component: () => <AnalyticsWidget /> })
// plugin-user-stats/index.ts — another plugin
widgets.api.insert({ Component: () => <UserStatsWidget /> })
// Result:
// <aside>
// <nav>Core navigation</nav>
// <AnalyticsWidget />
// <UserStatsWidget />
// </aside>Every inserted Component is automatically wrapped in React.memo. Root may be mounted in several places at once; each mount renders the same inserted list.
Canonical option order is filter → mapProps → Component → order (enforced by the ESLint plugin; the order is load-bearing for TypeScript inference, see below).
| Option | Required | Purpose |
|---|---|---|
filter |
no | Gate re-renders based on slot props (freeze semantics, see below) |
mapProps |
no* | Transform slot props into the inserted component's props |
Component |
yes | The component to render; auto-memoized |
order |
no | Render position: ascending, default 0, ties keep insertion order |
* mapProps is required whenever filter is used, and it is the only way slot props reach an inserted component.
Type the slot with a type argument; Root then requires those props:
const widgets = createSlot<{ stars: number; issues: number }>()
<widgets.Root stars={1240} issues={24} />Slot props reach an inserted component only through mapProps:
// reshape
widgets.api.insert({
mapProps: (props) => ({ value: props.issues }),
Component: Issues, // receives { value: number }
})
// pass through as-is
widgets.api.insert({
mapProps: (props) => props,
Component: (props) => <Stars value={props.stars} />,
})
// ignore slot props entirely — mapProps omitted, Component gets no props
widgets.api.insert({ Component: () => <StaticBanner /> })For an untyped slot (createSlot() with no type argument) the types forbid filter and mapProps — only Component and order are accepted.
The slot-props type T must be a plain object without key/ref fields (or void).
Components render sorted by order, ascending; missing order counts as 0; equal values keep insertion order (stable):
widgets.api.insert({ Component: PullRequests, order: 1 })
widgets.api.insert({ Component: Issues, order: 2 })
widgets.api.insert({ Component: Stars, order: 0 })
// renders: Stars, PullRequests, Issuesfilter gates whether an inserted component reacts to slot-prop changes. When slot props change:
filter(props)returnstrue→ the component re-renders withmapProps(props).filter(props)returnsfalse→ the component keeps its last accepted render (frozen output). It is not unmounted.
If filter rejects on the very first render, nothing renders until it first passes.
const widgets = createSlot<{ stars: number }>()
widgets.api.insert({
filter: (props) => props.stars % 2 === 0,
mapProps: (props) => props,
Component: (props) => <Stars value={props.stars} />,
})
// <widgets.Root stars={10} /> → renders 10
// stars becomes 11 → still shows 10 (frozen)
// stars becomes 12 → updates to 12filter supports type-predicate narrowing — when written as a type guard, mapProps receives the narrowed type:
slot.api.insert({
filter: (props): props is { user: User } => props.user !== null,
mapProps: (props) => ({ name: props.user.name }), // props.user is User here
Component: (props) => <Badge name={props.name} />,
})On discriminated-union slot props the explicit annotation is unnecessary — a plain lambda narrows via inferred type predicates when the check narrows the parameter itself:
const slot = createSlot<{ kind: "a"; a: number } | { kind: "b" }>()
slot.api.insert({
filter: (props) => props.kind === "a",
mapProps: (props) => ({ n: props.a }), // props narrowed to { kind: "a"; a: number }
Component: (props) => <N value={props.n} />,
})This only works when the whole parameter is narrowed (discriminant check, instanceof, etc.). A check that narrows only a property — (props) => props.user !== null on a non-union type — never produces a predicate; write the explicit props is ... guard there.
Caveat: narrowing (explicit or inferred) only kicks in when filter is written above mapProps in the object literal. Reversed order silently falls into the boolean overload and narrowing is lost — the ESLint rule insert-options-order exists to catch this.
The freeze is concurrent-safe: render attempts React abandons (interrupted transitions, suspended attempts) cannot leak a never-committed element into the frozen output.
createSlot({ Fallback }) renders a fallback while the slot has never received an insert:
const widgets = createSlot<{ stars: number }>({
Fallback: (props) => <Skeleton width={props.stars > 100 ? "lg" : "sm"} />,
})Semantics:
Fallbackreceives exactly the slot props ofcreateSlot<T>(none whenTis not given).Tis never inferred from theFallbacksignature — it comes only from the explicit type argument.- The first
insertremoves the fallback permanently: a laterclear()empties the slot but does not bring the fallback back (one-shot latch). - Emptiness is list-based: the fallback tracks
insert/clear, not what the inserted children actually render. A slot whose only child rendersnullshows nothing, not the fallback. - On the server it renders whenever nothing was inserted at render time.
- Composes with
presence: true; the fallback is not probed and never counts towarduseCount/usePresence. - The fallback component is auto-memoized.
createSlot({ presence: true }) additionally returns two hooks:
const widgets = createSlot({ presence: true })
const Heading = () => {
const count = widgets.useCount() // number of inserted components currently rendering real DOM content
return count > 0 ? <h2>Widgets</h2> : null
}
const Debug = () => {
const flags = widgets.usePresence() // readonly boolean[], one per inserted component, in render order
// ...
}Semantics:
- "Present" means the inserted component currently renders at least one DOM node. A mounted component that returns
null(or whose subtree collapses to nothing) counts as absent. This is what makesuseCountreliable for "render a heading only when the slot renders DOM content". - Implementation: each inserted component is wrapped in a
<span style="display: contents">probe; aMutationObserveron the span's child list reports presence changes.display: contentsremoves the span from layout, so styling is unaffected. - Updates are batched in a microtask; the returned array has stable identity when nothing changed.
- If
Rootis mounted in several places, a component is "present" when any mount renders its content. - SSR/hydration: on the server
useCountreturns0andusePresencereturns all-false; real values appear after hydration (probes are DOM-based). Hydration does not trigger React's "getServerSnapshot should be cached" error — server snapshots are identity-cached. - Presence costs extra (probe span + observer per inserted item) — that is why it is opt-in.
Limitations:
- Tag-name-significant children. The probe wraps every inserted child in a
<span style="display: contents">. Aspanis invalid inside content-model-strict parents:<table>/<tbody>/<tr>(children must betr/td),<select>(option),<picture>/<audio>/<video>(source), and anything inside SVG (wrong namespace). Consequences: ReactvalidateDOMNestingwarnings, broken table/select rendering, hydration mismatch risk. Do not enablepresence: trueon slots whose children must be such elements (tr,td,option,source, SVG elements). This is a deliberate design boundary. - Portal-only children. A child that renders everything through
createPortalleaves its probe span empty and counts as absent even though its content is on screen. Deliberate: presence has region semantics — "is there DOM in the slot's region", not "is it showing somewhere". - Has-DOM, not visibility. A child whose DOM node exists but is invisible (
display: none, zero-size, off-screen) still counts as present. display: contentsrequires ~2018+ browsers (Chrome 65+, Safari 11.1+).
Without presence: true these hooks do not exist on the slot object.
api.clear() removes all inserted components (empty render). It does not restore Fallback (see above). Inserting after clear works normally.
import { createSlot } from "@grlt-hub/react-slots"
// slot.ts
export const widgets = createSlot<{ stars: number }>({
presence: true,
Fallback: () => <p>No widgets yet</p>,
})
// host.tsx
const Dashboard = () => {
const count = widgets.useCount()
return (
<aside>
{count > 0 && <h2>Widgets ({count})</h2>}
<widgets.Root stars={1240} />
</aside>
)
}
// plugin.ts — anywhere, any time
widgets.api.insert({
filter: (props) => props.stars > 0,
mapProps: (props) => ({ value: props.stars }),
Component: (props) => <Stars value={props.value} />,
order: 1,
})insert mutates an external store that Root subscribes to via useSyncExternalStore — the same pattern as Redux, Zustand, or any external-store integration React officially supports. The imperative call site is the point: plugins register themselves at module load, without the host rendering tree knowing about them.
- npm: https://www.npmjs.com/package/@grlt-hub/eslint-plugin-react-slots
- Requires ESLint 9+ (or 10), TypeScript 5+ (or 6), and typed linting (
parserOptions.projectService).
// eslint.config.js (flat config)
import reactSlots from "@grlt-hub/eslint-plugin-react-slots"
import tseslint from "typescript-eslint"
export default tseslint.config(reactSlots.configs.recommended)Rules:
insert-options-order(warn inrecommended, auto-fixable) — enforcesfilter → mapProps → Component → orderininsertcalls. Type-aware: it identifiesinsertby its type, so it works through aliases, destructuring, and re-exports, and never fires on aninsertfrom another library. The order matters becausefilter's type-predicate narrowing only fires whenfilteris written abovemapProps.
v3 concepts and their v5 equivalents:
createSlots({ X: createSlotIdentifier() })→ onecreateSlot()per slot; no registry object.slotsApi.X.insert(...)→slot.api.insert(...);<Slots.X />→<slot.Root />.when(effector-based deferred insertion) → removed; callinsertwhen your condition is met.- effector / effector-react / nanoid peer dependencies → removed entirely.