| name | add-slots |
| description | Adds slot-based extensibility to the application — defines slot types in app-shared, contributes items from modules, and consumes them in the shell. Use when the shell needs to collect and render contributions from multiple modules (command palettes, system registrations, dashboard widgets, etc.). |
| metadata | {"author":"reactive","version":"1.0"} |
Add Slots
Slots are the extensibility primitive for collecting contributions from many modules into a single place. Each slot is a named array — modules contribute items, and the registry concatenates them at resolve time. The shell reads the merged result via useSlots().
Use slots when:
- The shell has a UI region that should display items from multiple modules (command palette, dashboard widgets, system integrations).
- The shell doesn't know at build time what items will exist — modules declare them.
Do NOT use slots for:
- Per-route UI regions — use zones instead.
- Shared state — use stores.
Step 1: Define the slot item type in app-shared
export interface CommandDefinition {
readonly id: string;
readonly label: string;
readonly group?: string;
readonly icon?: string;
readonly onSelect: () => void;
}
Step 2: Define the AppSlots interface
export interface AppSlots {
commands: CommandDefinition[];
}
Every value in AppSlots must be an array type. Non-array types produce a compile error.
Step 3: Pass AppSlots to the registry and defineModule
import type { AppDependencies, AppSlots } from "@example/app-shared";
const registry = createRegistry<AppDependencies, AppSlots>({
stores: { auth: authStore, config: configStore },
services: { httpClient },
slots: { commands: [] },
});
import type { AppDependencies, AppSlots } from "@example/app-shared";
export default defineModule<AppDependencies, AppSlots>({
id: "billing",
version: "0.1.0",
});
Step 4: Contribute slot items from modules
export default defineModule<AppDependencies, AppSlots>({
id: "billing",
version: "0.1.0",
slots: {
commands: [
{
id: "billing:dashboard",
label: "Open Billing Dashboard",
group: "navigate",
onSelect: () => {},
},
{
id: "billing:invoices",
label: "View Invoices",
group: "navigate",
onSelect: () => {},
},
],
},
});
Step 5: Consume slots in the shell
import { useSlots } from "@react-router-modules/runtime";
import type { AppSlots } from "@example/app-shared";
export function CommandPalette() {
const slots = useSlots<AppSlots>();
return (
<ul>
{slots.commands.map((cmd) => (
<li key={cmd.id}>
<button onClick={cmd.onSelect}>{cmd.label}</button>
</li>
))}
</ul>
);
}
Headless modules with defineSlots
For modules that only contribute slot items (no routes, no component, no lifecycle), use the defineSlots shorthand:
import { defineSlots } from "@react-router-modules/core";
import type { AppDependencies, AppSlots } from "@example/app-shared";
export default defineSlots<AppDependencies, AppSlots>("external-systems", {
systems: [{ id: "salesforce", name: "Salesforce", icon: "cloud" }],
});
This is equivalent to defineModule({ id: "external-systems", version: "0.0.0", slots: { ... } }) but more concise.
Adding a new slot type
When you need a new extensibility point:
- Define the item interface in
app-shared/src/index.ts.
- Add the slot to the
AppSlots interface.
- Add a default value in
createRegistry() config: slots: { commands: [], newSlot: [] }.
- Contribute from modules via the
slots field in defineModule().
- Consume in the shell via
useSlots<AppSlots>().
Dynamic (conditional) slots
When slot contributions depend on runtime state (user role, permissions, feature flags), use dynamicSlots on the module descriptor instead of (or alongside) static slots:
export default defineModule<AppDependencies, AppSlots>({
id: "billing",
version: "0.1.0",
slots: {
commands: [
{ id: "billing:dashboard", label: "Open Billing", group: "navigate", onSelect: () => {} },
],
},
dynamicSlots: (deps) => ({
commands:
deps.auth.user?.role === "admin"
? [
{
id: "billing:void-invoice",
label: "Void Invoice",
group: "actions",
onSelect: () => {},
},
]
: [],
}),
requires: ["auth"],
});
dynamicSlots receives a snapshot of all shared dependencies (stores yield their current state). Results are concatenated with static slots.
Return empty arrays for slots that should contribute nothing — this is cleaner than omitting the key.
Triggering recalculation
Dynamic slots are not re-evaluated automatically. Call recalculateSlots() (returned from registry.resolve()) after the state that dynamic slots depend on has changed:
const { App, recalculateSlots } = registry.resolve({
rootComponent: Layout,
indexComponent: Home,
});
await authStore.getState().login(credentials);
recalculateSlots();
recalculateSlots() is a no-op when no module uses dynamicSlots and no slotFilter is configured.
Modules can also trigger recalculation from their own components via the useRecalculateSlots() hook from @react-router-modules/runtime.
Slot filter (cross-cutting)
For permission-based filtering that spans all modules, use slotFilter on resolve():
const { App, recalculateSlots } = registry.resolve({
rootComponent: Layout,
slotFilter: (slots, deps) => ({
...slots,
commands: slots.commands.filter(
(cmd) => !cmd.requiredRole || deps.auth.user?.roles.includes(cmd.requiredRole),
),
}),
});
The filter runs after all static and dynamic contributions are merged, on each recalculateSlots() call.
How slot merging works
At registry.resolve():
- Start with the
slots defaults from createRegistry() config.
- For each registered module, append its
slots[key] items to the corresponding array.
- Collect
dynamicSlots functions from all modules.
- When
recalculateSlots() is called, evaluate dynamic factories against the current deps snapshot, merge with static slots, and apply the slot filter.
- The result is provided via
SlotsContext.
Order of items follows module registration order. If you need sorting, sort in the consuming component.
When no module uses dynamicSlots and no slotFilter is configured, slots remain fully static with zero additional overhead.
Rules
- Every slot value must be an array type in
AppSlots.
- Always provide defaults in
createRegistry() config to guarantee every slot key exists.
- Pass
AppSlots as the second generic to both createRegistry<AppDependencies, AppSlots>() and defineModule<AppDependencies, AppSlots>().
- Modules can contribute to any subset of slots — omitted keys are simply not merged.
- Slot items are concatenated across modules, not replaced. Every module's contributions are included.
- Use
dynamicSlots when contributions depend on runtime state. Use static slots when contributions are always the same.
- Use
slotFilter on resolve() for cross-cutting filtering (permissions, feature flags) that spans all modules.
- Consume slots via
useSlots<AppSlots>() from @react-router-modules/runtime, not from core. The hook returns both static and dynamic contributions transparently.
- Do not import slot data from other modules directly. The registry merges contributions — modules don't need to know about each other.