@mikrostack/rst
v1.2.3
Published
React Slot-Based Component System
Readme
React Slot Component System
A type-safe, flexible slot-based component system for React applications. This system enables the creation of composable components with named "slots" that can be filled by children components.
Table of Contents
- Overview
- Installation
- Key Features
- API Reference
- Usage Examples
- Basic Usage
- Multiple Slot Instances
- Required Slots
- Default Content
- Non-Slot Children
- Same Component for Multiple Slots
- Nested Slot Components
- Injecting Runtime Props
- Static Prop Binding with
withProps - Remote Slot Ownership with
asChild - Typed Slot Context with
useSlotContext - Dot-path Slot Keys
- Reusable Slot Groups with
defineSlotGroup - Checking Slot Content with
isSlotFilled
- TypeScript Support
- Best Practices
- Real-World Applications
Overview
This slot component system provides a pattern for building complex, composable components in React with excellent TypeScript inference. Instead of prop drilling or complex component composition, the slots pattern enables a clean, intuitive API for component customization.
// Define slots and render function with a fluent API
const Card = createComponentWithSlots({
Header: {},
Body: {},
Footer: {}
}).render<{ className?: string }>(({ slots, className }) => (
<div className={className}>
{slots.Header}
{slots.Body}
{slots.Footer}
</div>
));
// Usage
<Card className="my-card">
<Card.Header>Card Title</Card.Header>
<Card.Body>Card content goes here</Card.Body>
<Card.Footer><Button>Action</Button></Card.Footer>
</Card>Installation
# Using npm
npm install @mikrostack/rst
# Using yarn
yarn add @mikrostack/rstKey Features
- Type-Safe: Full TypeScript support with proper type inference, including per-slot element types
- Composable: Clean, intuitive component composition
- Multiple Slot Instances: Support for multiple children of the same slot type, with automatic key assignment
- Validation: Required slot validation
- Default Content: Support for default slot content
- Non-Slot Children Handling: Collect and handle non-matching children
- Flexible Rendering: Full control over slot positioning and layout
- Same Component for Multiple Slots: Two slots can share the same underlying component — RST uses per-slot Symbols for identity, not component reference
withProps: Bind static props to a component at definition time, removing them from the public slot surfaceasChild: Prop on any slot component that lets a remote component (with its own state and queries) fill the slot without co-locationinjectSlotProps: Typed helper for passing render-function state into a slot without modifying the slots APIuseSlotContext: Typed hook for slot components to consume parent render state without manually wiring React context outside the slot system- Dot-path slot keys: Define hierarchical slot namespaces (
"Header.Title") that automatically generate nested static accessors (Page.Header.Title) while keeping the render-function API flat (slots["Header.Title"]) prefixSlots: Low-level helper that prefixes a slot config record — the primitive on whichdefineSlotGroupis builtdefineSlotGroup: Encapsulates a group of related slots (config + render logic) into a reusable unit that can be spread into any parent slot configisSlotFilled: Checks whether a slot has content — supports exact keys, key arrays, and wildcard prefix matching, withall/somesemantics
API Reference
createComponentWithSlots
// Without context
function createComponentWithSlots<S extends Record<string, SlotConfig>>(
slotsConfig: S
): ComponentBuilder<S>
// With context
function createComponentWithSlots<S extends Record<string, SlotConfig>, C extends object>(
slotsConfig: S,
options: { context: C }
): ComponentBuilderWithContext<S, C>Returns a builder object with the render method.
builder.render<T>(renderFn)
Define the component's render function with optional custom props.
// Without context
render<T extends object = {}>(
render: (props: T & { slots: {...}; nonSlotChildren: ReactElement[] }) => ReactElement
): React.FC<T & { children?: ReactNode }> & ExtractSlotComponents<S>
// With context — render function also receives provideContext
render<T extends object = {}>(
render: (props: T & { slots: {...}; nonSlotChildren: ReactElement[]; provideContext: (value: C) => void }) => ReactElement
): React.FC<T & { children?: ReactNode }> & ExtractSlotComponents<S> & ContextComponent<C>Type parameter T: Custom component props (defaults to {} if omitted)
Parameters
slotsConfig: An object mapping slot names to slot configuration objects. Each configuration can include:
component: Optional custom slot component. If omitted, the slot's content renders directly without a wrapper element.isRequired: If true, the slot must be providedmultiple: If true, multiple instances of the slot are collected in an array. Children without akeyreceive one automatically based on their index.defaultContent: Default content to use if the slot is not provided
options.context (optional): An object defining the shape and default values of the slot context. When provided, RST creates a scoped store for this component and makes provideContext available in the render function. The default values are used as the initial store state and as the fallback when useSlotContext is called outside a provider.
render: Function that renders the component using the organized slots. When context is configured, also receives provideContext — call it with the current context values on every render.
Returns
A React component with slot component functions attached as static properties. When context is configured, the returned component also carries __storeContext for use with useSlotContext.
withProps
function withProps<P extends object, B extends Partial<P>>(
Component: (props: P) => ReactNode,
boundProps: B,
): (props: Omit<P, keyof B>) => ReactNodeReturns a new component with boundProps pre-applied. The bound keys are removed from the returned component's prop surface — the type system correctly reflects what the consumer still needs to provide.
Bound props act as defaults: any prop the consumer passes directly on the slot element takes priority and overrides the bound value.
withProps is slot-agnostic and can be used anywhere, but it is particularly useful in slot configs to bind static, definition-time values without touching the render function.
injectSlotProps
function injectSlotProps<P>(
slot: ReactElement<P> | null,
props: Partial<P>
): ReactElement<P> | nullA typed wrapper around cloneElement for passing render-function state (e.g. callbacks, open/close flags) into a slot without changing the slots.X API. Returns null when the slot is absent, so it is safe to use unconditionally.
import { injectSlotProps } from "@mikrostack/rst";
// Instead of: {slots.Dialog}
{injectSlotProps(slots.Dialog, { onClose: () => setOpen(false) })}injectSlotProps is the only mechanism for passing runtime props to a slot. The props argument is typed against the slot component's own prop type, so mismatched props are caught at compile time.
isSlotFilled
function isSlotFilled(
slots: Record<string, ReactNode | ReactNode[]>,
pattern: string | string[],
all?: boolean,
): booleanChecks whether a slot or group of slots has content. Handles both single slots (null check) and multiple slots (non-empty array check) transparently. Pass the slots object from the render function as the first argument.
pattern — one of:
- Exact key (
"Header.Title") — checks a single slot.allis ignored. - Key array (
["Header.Title", "Header.Action"]) — checks an explicit set of slots. - Wildcard (
"Header*") — checks all slots whose key starts with the prefix before*.
all (optional, default false) — when using a key array or wildcard:
false— returnstrueif at least one matching slot is filledtrue— returnstrueonly if all matching slots are filled
import { isSlotFilled } from "@mikrostack/rst";
.render(({ slots }) => {
const hasHeader = isSlotFilled(slots, "Header*");
const hasTitleOrActions = isSlotFilled(slots, ["Header.Title", "Header.Action"]);
const allHeadersFilled = isSlotFilled(slots, "Header*", true);
return (
<div>
{hasHeader && (
<header>
{hasTitleOrActions && (
<div className="title-row">
{slots["Header.Title"]}
{isSlotFilled(slots, "Header.Action") && (
<div className="actions">{slots["Header.Action"]}</div>
)}
</div>
)}
{slots["Header.Form"]}
</header>
)}
</div>
);
})
---
### `useSlotContext`
```typescript
// Selector overload — recommended
function useSlotContext<C extends object, T>(
layout: ContextComponent<C>,
selector: (value: C) => T,
): T
// Full-shape overload
function useSlotContext<C extends object>(
layout: ContextComponent<C>,
): CReads a value from the typed slot context of a layout component. Must be called from inside a component that is rendered within the layout's slot tree.
layout: The slotted component returned by createComponentWithSlots(..., { context }). Passing a component without a context option is a compile-time error.
selector (optional): A function that picks a specific value from the context shape. Re-renders the caller only when the selected value changes. Omit to subscribe to the full context object — the caller then re-renders on any context update.
The selector form is strongly preferred when only one or two values are needed. It keeps re-renders scoped and makes the consumed values explicit at the call site.
When called outside any instance of layout, useSlotContext returns the default values declared in the context option.
prefixSlots
function prefixSlots<Prefix extends string, S extends Record<string, SlotConfig>>(
prefix: Prefix,
config: S,
): PrefixedConfig<Prefix, S>Returns a new slot config record with every key prefixed by prefix + ".". This is the low-level primitive used by defineSlotGroup; reach for it when you need to merge prefixed config manually or build a custom group abstraction.
import { prefixSlots } from "@mikrostack/rst";
const headerConfig = prefixSlots("Header", {
Title: {},
Actions: { multiple: true },
});
// → { "Header.Title": {}, "Header.Actions": { multiple: true } }defineSlotGroup
function defineSlotGroup<Prefix extends string, S extends Record<string, SlotConfig>>(
prefix: Prefix,
config: S,
renderFn: (args: { slots: RenderedSlots<PrefixedConfig<Prefix, S>> }) => ReactElement,
): {
config: () => PrefixedConfig<Prefix, S>;
render: (slots: RenderedSlots<PrefixedConfig<Prefix, S>>) => ReactElement;
}Packages a group of related slots together with their render logic into a reusable unit. The returned object has two members:
.config(): Returns the prefixed slot config — spread it into a parent's slot config.
.render(slots): Calls the group's render function with the parent's fully resolved slots object. Call this from the parent's render function wherever the group's output should appear.
Multiple groups can be spread into the same parent without conflict as long as their prefixes differ.
Usage Examples
Basic Usage
// 1. Create component with slots (using default wrappers)
const Card = createComponentWithSlots({
Header: {},
Body: {},
Footer: {}
}).render<{ className?: string }>(({ slots, className }) => (
<div className={`card ${className || ''}`}>
{slots.Header}
{slots.Body}
{slots.Footer}
</div>
));
// 2. Usage
function App() {
return (
<Card className="custom-card">
<Card.Header>My Card Title</Card.Header>
<Card.Body>Card content goes here...</Card.Body>
<Card.Footer>
<button>Click me</button>
</Card.Footer>
</Card>
);
}Component Without Custom Props
Use the render() method for components that don't need custom props:
const Simple = createComponentWithSlots({
Header: {},
Body: {}
}).render(({ slots }) => (
<div>
{slots.Header}
{slots.Body}
</div>
));
// Usage
<Simple>
<Simple.Header>Title</Simple.Header>
<Simple.Body>Content</Simple.Body>
</Simple>Custom Slot Components
If you need custom styling or behavior, provide a component:
// Define custom slot component
function HeaderSlot({ children }: PropsWithChildren) {
return <div className="custom-header">{children}</div>;
}
// Use custom component
const Card = createComponentWithSlots({
Header: { component: HeaderSlot }, // Custom component
Body: {}, // Default wrapper
Footer: {}
}).render(({ slots }) => (
<div>
{slots.Header}
{slots.Body}
{slots.Footer}
</div>
));Multiple Slot Instances
Children of a multiple slot that have no key prop automatically receive an index-based key. Rendering the collected array directly causes no React key warnings.
const TagList = createComponentWithSlots({
Tag: { multiple: true },
}).render(({ slots }) => (
<div className="tags">
{slots.Tag}
</div>
));
// No key props needed on the children
<TagList>
<TagList.Tag>React</TagList.Tag>
<TagList.Tag>TypeScript</TagList.Tag>
<TagList.Tag>RST</TagList.Tag>
</TagList>If you wrap each collected element in a container inside the render function, those wrapper elements need their own keys as usual:
const Tabs = createComponentWithSlots({
Tab: { multiple: true },
}).render<{ activeTab?: number }>(({ slots, activeTab = 0 }) => (
<div className="tabs">
{slots.Tab.map((tab, index) => (
<div key={index} className={activeTab === index ? "tab tab--active" : "tab"}>
{tab}
</div>
))}
</div>
));Required Slots
const Form = createComponentWithSlots({
Fields: { isRequired: true }
}).render(({ slots }) => (
<form>
{slots.Fields}
</form>
));
// Missing Form.Fields causes a console error in development
<Form>
<Form.Fields>...</Form.Fields>
</Form>Default Content
const Panel = createComponentWithSlots({
Body: {},
Footer: {
defaultContent: <div className="default-footer">© 2025 Company Inc.</div>
}
}).render(({ slots }) => (
<div>
{slots.Body}
{slots.Footer}
</div>
));
// Footer shows default content when not provided
<Panel>
<Panel.Body>Main content</Panel.Body>
</Panel>Non-Slot Children
const Layout = createComponentWithSlots({
Header: {},
Sidebar: {},
Footer: {}
}).render(({ slots, nonSlotChildren }) => (
<div className="layout">
{slots.Header}
<div className="content">
{slots.Sidebar}
<main>
{nonSlotChildren}
</main>
</div>
{slots.Footer}
</div>
));
// Usage
<Layout>
<Layout.Header>Site Header</Layout.Header>
<Layout.Sidebar>Navigation</Layout.Sidebar>
<div>Main content section 1</div>
<div>Main content section 2</div>
<Layout.Footer>Site Footer</Layout.Footer>
</Layout>Same Component for Multiple Slots
Two slots can share the same underlying component. RST identifies slots by a per-slot Symbol assigned at registration time — not by component reference — so there is no ambiguity. Use injectSlotProps in the render function to pass slot-specific props at render time:
function SidebarSlot({ side, children }: { side: "left" | "right"; children?: ReactNode }) {
return <aside className={`sidebar sidebar--${side}`}>{children}</aside>;
}
const Layout = createComponentWithSlots({
LeftSidebar: { component: SidebarSlot },
RightSidebar: { component: SidebarSlot },
Body: {},
}).render(({ slots }) => (
<div className="layout">
{injectSlotProps(slots.LeftSidebar, { side: "left" })}
{slots.Body}
{injectSlotProps(slots.RightSidebar, { side: "right" })}
</div>
));
// Both slots resolve correctly even though they share SidebarSlot
<Layout>
<Layout.LeftSidebar>Nav</Layout.LeftSidebar>
<Layout.RightSidebar>Aside</Layout.RightSidebar>
</Layout>Nested Slot Components
A slot whose component is itself a slotted component automatically exposes the inner component's slots as static properties, so consumers can address them with a chained path:
const Header = createComponentWithSlots({
Title: {},
Actions: { multiple: true },
}).render(({ slots }) => (
<header>
{slots.Title}
<div className="actions">{slots.Actions}</div>
</header>
));
const Page = createComponentWithSlots({
Header: { component: Header },
Body: {},
}).render(({ slots }) => (
<div>
{slots.Header}
{slots.Body}
</div>
));
// Usage — chained slot access
<Page>
<Page.Header>
<Page.Header.Title>My Page</Page.Header.Title>
<Page.Header.Actions>
<button>Save</button>
</Page.Header.Actions>
</Page.Header>
<Page.Body>Content</Page.Body>
</Page>Remote Slot Ownership with asChild
A slot's content often needs to live in a separate file — it has its own queries, mutations, and local state that don't belong at the callsite. asChild lets a remote component fill a slot without the parent knowing anything about it, and without the remote component knowing which layout it is used in.
// Layout definition
const PageLayout = createComponentWithSlots({
Header: { isRequired: true },
Body: {},
}).render(({ slots }) => (
<div>
<div className="header">{slots.Header}</div>
<div className="body">{slots.Body}</div>
</div>
));
// Remote component — owns its own state, unaware of PageLayout
function RouterHeader({ routerId }: { routerId: number }) {
const { data } = useRouterData(routerId); // its own query
const [open, setOpen] = useState(false); // its own state
return (
<div>
<h1>{data?.name}</h1>
<button onClick={() => setOpen(true)}>Options</button>
</div>
);
}
// Usage — slot identity is explicit at the callsite; RouterHeader has no coupling to PageLayout
<PageLayout>
<PageLayout.Header asChild>
<RouterHeader routerId={42} />
</PageLayout.Header>
<PageLayout.Body>Content</PageLayout.Body>
</PageLayout>Semantics:
asChildis only valid on slot components — not on the parent layout itself- The child must be a single React element; a non-element child logs an error in development
- The slot wrapper dissolves at collection time —
RouterHeaderrenders directly in the slot position - All other slot config (
isRequired,multiple,defaultContent) applies to the slot position as normal;asChildonly affects how the content is collected - Works with
multipleslots — eachasChildwrapper is treated as one instance
Nested slotted components:
asChild bypasses exactly one level — the slot component whose asChild prop is set. If that slot's component is itself a slotted component, the remote component must still respect its slot contract.
// PageTitle is a slotted component used as PageHeader's Title slot component
const PageTitle = createComponentWithSlots({
Icon: {},
Heading: { isRequired: true },
}).render(({ slots }) => (
<header>
{slots.Icon}
{slots.Heading}
</header>
));
const PageHeader = createComponentWithSlots({
Title: { component: PageTitle, isRequired: true },
Form: {},
}).render(/* ... */);
const Page = createComponentWithSlots({
Header: { component: PageHeader },
Body: {},
}).render(/* ... */);
// ✓ asChild on Page.Header — PageHeader is bypassed.
// RemoteHeader controls the layout, but must still satisfy PageTitle's contract
// when using Page.Header.Title inside it.
function RemoteHeader() {
return (
<div>
<Page.Header.Title>
<Page.Header.Title.Heading>Dashboard</Page.Header.Title.Heading>
</Page.Header.Title>
<Page.Header.Form>...</Page.Header.Form>
</div>
);
}
<Page>
<Page.Header asChild>
<RemoteHeader />
</Page.Header>
</Page>
// ✗ Wrong — PageTitle expects Heading as a slot element, not a plain string child
function BrokenRemoteHeader() {
return (
<div>
<Page.Header.Title>Dashboard</Page.Header.Title> {/* Heading slot not filled */}
</div>
);
}asChild is designed to make code-splitting easier, not to break slot contracts. A remote component filling a slot via asChild is responsible for knowing and satisfying the slot component's contract.
Comparison of approaches:
| Approach | Slot identity visible at callsite | Remote component is layout-agnostic | No changes to remote component |
|---|---|---|---|
| headerSlot prop | ✗ | ✓ | ✓ |
| Self-wrapping in slot | ✗ | ✗ | ✗ |
| asChild | ✓ | ✓ | ✓ |
Static Prop Binding with withProps
withProps binds static props at definition time. The render function stays clean, and the bound keys disappear from the slot's public prop surface.
import { createComponentWithSlots, withProps } from "@mikrostack/rst";
function SidebarSlot({ side, children }: { side: "left" | "right"; children?: ReactNode }) {
return <aside className={`sidebar sidebar--${side}`}>{children}</aside>;
}
// Without withProps — side must be injected at render time
const Layout = createComponentWithSlots({
LeftSidebar: { component: SidebarSlot },
RightSidebar: { component: SidebarSlot },
}).render(({ slots }) => (
<div>
{injectSlotProps(slots.LeftSidebar, { side: "left" })}
{injectSlotProps(slots.RightSidebar, { side: "right" })}
</div>
));
// With withProps — side is bound at definition time, render function stays clean
const Layout = createComponentWithSlots({
LeftSidebar: { component: withProps(SidebarSlot, { side: "left" }) },
RightSidebar: { component: withProps(SidebarSlot, { side: "right" }) },
}).render(({ slots }) => (
<div>
{slots.LeftSidebar}
{slots.RightSidebar}
</div>
));
// Usage — side is no longer part of the slot's public API
<Layout>
<Layout.LeftSidebar>Nav</Layout.LeftSidebar>
<Layout.RightSidebar>Aside</Layout.RightSidebar>
</Layout>| | withProps | injectSlotProps |
|---|---|---|
| When | Definition time | Render time |
| Input | Component + static props | Element + dynamic props |
| Returns | New component type | Cloned element |
| Use case | Props that never change | Props that depend on render state |
Injecting Runtime Props
Use injectSlotProps to forward render-function state (callbacks, open flags, refs) into a slot without altering the slots.X shape:
import { createComponentWithSlots, injectSlotProps } from "@mikrostack/rst";
function DialogSlot({ onClose, children }: { onClose?: () => void; children?: ReactNode }) {
return (
<dialog>
{children}
<button onClick={onClose}>Close</button>
</dialog>
);
}
const Page = createComponentWithSlots({
Dialog: { component: DialogSlot },
}).render(({ slots }) => {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(true)}>Open</button>
{open && injectSlotProps(slots.Dialog, { onClose: () => setOpen(false) })}
</div>
);
});
// Usage
<Page>
<Page.Dialog>Dialog content here</Page.Dialog>
</Page>Typed Slot Context with useSlotContext
Slots that need to read or drive parent state — open/close flags, callbacks, orientation — can use useSlotContext instead of manually wiring a React context outside the slot system. The context shape is declared once in the config and is automatically typed at every call site.
import { createComponentWithSlots, useSlotContext } from "@mikrostack/rst";
// Declare the context shape and defaults alongside the slot config
const Panel = createComponentWithSlots(
{
Header: {},
Trigger: {},
Body: {},
},
{
context: {
open: false,
toggle: () => {},
},
},
).render(({ slots, provideContext }) => {
const [open, setOpen] = useState(false);
// Called on every render — RST pushes the value to the store after the commit
provideContext({ open, toggle: () => setOpen(o => !o) });
return (
<div className="panel">
<div className="panel-bar">
{slots.Header}
{slots.Trigger}
</div>
{open && <div className="panel-body">{slots.Body}</div>}
</div>
);
});
// Selector overload — re-renders only when `open` changes
function PanelStatusBadge() {
const open = useSlotContext(Panel, s => s.open);
return <span>{open ? "Open" : "Closed"}</span>;
}
// Full-shape overload — reads both `open` and `toggle`
function PanelToggleButton() {
const { open, toggle } = useSlotContext(Panel);
return <button onClick={toggle}>{open ? "Collapse" : "Expand"}</button>;
}
// Usage — both slot components are completely decoupled from Panel's internals
<Panel>
<Panel.Header>
Settings <PanelStatusBadge />
</Panel.Header>
<Panel.Trigger>
<PanelToggleButton />
</Panel.Trigger>
<Panel.Body>
<p>Content visible when open.</p>
</Panel.Body>
</Panel>Key points:
- The
contextoption declares the shape and initial values. These are also the fallback values returned byuseSlotContextwhen called outside a<Panel>instance. provideContextis called on every render with the current values. RST captures it during the render pass and pushes it to the store after the commit —store.setis never called during a React render.- Each mounted instance of
Panelhas its own isolated store. Two<Panel>components on the same page do not share context. - The selector form (
useSlotContext(Panel, s => s.open)) re-renders the caller only when the selected value changes. Prefer it over the full-shape overload when only one or two values are needed. - Passing a layout without a
contextoption touseSlotContextis a compile-time error.
provideContext and defaults:
The context defaults should match the component's initial state. If open: false is the initial state, declare open: false as the default. This avoids a one-frame mismatch between the default store value and the first rendered state.
// ✓ Defaults match initial useState values — no mismatch
createComponentWithSlots({ ... }, { context: { open: false, toggle: () => {} } })
.render(({ provideContext }) => {
const [open, setOpen] = useState(false); // matches default
provideContext({ open, toggle: () => setOpen(o => !o) });
// ...
});Dot-path Slot Keys
When a slot config key contains dots ("Header.Title"), RST automatically generates a nested static accessor on the component (Page.Header.Title). The render function always uses the flat string form (slots["Header.Title"]).
import { createComponentWithSlots } from "@mikrostack/rst";
const Page = createComponentWithSlots({
"Header.Title": {},
"Header.Actions": { multiple: true },
Body: { isRequired: true },
}).render(({ slots }) => (
<div>
<header>
{slots["Header.Title"]}
<div>{slots["Header.Actions"]}</div>
</header>
<main>{slots.Body}</main>
</div>
));
// Static accessors generated automatically:
// Page.Header.Title, Page.Header.Actions, Page.Body
<Page>
<Page.Header.Title>My Page</Page.Header.Title>
<Page.Header.Actions>Save</Page.Header.Actions>
<Page.Header.Actions>Cancel</Page.Header.Actions>
<Page.Body>…</Page.Body>
</Page>Dot-path keys support arbitrary depth ("A.B.C" → Component.A.B.C). Plain keys and dot-path keys can be freely mixed in the same config. The dot notation is purely a namespacing convention for the static accessor — slot identity and collection remain the same as with plain keys.
Reusable Slot Groups with defineSlotGroup
defineSlotGroup bundles a set of related slots with their render markup into a reusable unit, then lets multiple parent components share that unit without duplicating config or rendering code.
import { createComponentWithSlots, defineSlotGroup } from "@mikrostack/rst";
const headerGroup = defineSlotGroup(
"Header",
{ Title: {}, Actions: { multiple: true } },
({ slots }) => (
<header>
<h1>{slots["Header.Title"]}</h1>
<div className="actions">{slots["Header.Actions"]}</div>
</header>
),
);
// Spread the group config into one or more parent components
const Page = createComponentWithSlots({
...headerGroup.config(),
Body: { isRequired: true },
}).render(({ slots }) => (
<div>
{headerGroup.render(slots)}
<main>{slots.Body}</main>
</div>
));
const Dialog = createComponentWithSlots({
...headerGroup.config(),
Content: {},
Footer: {},
}).render(({ slots }) => (
<div className="dialog">
{headerGroup.render(slots)}
<div className="dialog__body">{slots.Content}</div>
<footer>{slots.Footer}</footer>
</div>
));
// Both components expose the same Header.* accessor surface
<Page>
<Page.Header.Title>Dashboard</Page.Header.Title>
<Page.Body>…</Page.Body>
</Page>
<Dialog>
<Dialog.Header.Title>Confirm</Dialog.Header.Title>
<Dialog.Content>Are you sure?</Dialog.Content>
</Dialog>Multiple groups can be composed into the same parent as long as their prefixes differ:
const footerGroup = defineSlotGroup("Footer", { Links: {}, Copyright: {} }, …);
const Layout = createComponentWithSlots({
...headerGroup.config(),
...footerGroup.config(),
}).render(({ slots }) => (
<div>
{headerGroup.render(slots)}
<main>…</main>
{footerGroup.render(slots)}
</div>
));Checking Slot Content with isSlotFilled
Use isSlotFilled inside a render function to conditionally render wrapper elements around slots — avoiding empty containers when optional slots are unprovided.
import { createComponentWithSlots, isSlotFilled } from "@mikrostack/rst";
const Article = createComponentWithSlots({
"Header.Title": {},
"Header.Action": { multiple: true },
"Header.Form": {},
"Body.Content": { isRequired: true },
}).render(({ slots }) => {
// true if any Header.* slot has content
const hasHeader = isSlotFilled(slots, "Header*");
// true if Title or at least one Action is filled
const hasTitleRow = isSlotFilled(slots, ["Header.Title", "Header.Action"]);
return (
<article>
{hasHeader && (
<header>
{hasTitleRow && (
<div className="title-row">
{slots["Header.Title"]}
{isSlotFilled(slots, "Header.Action") && (
<div className="actions">{slots["Header.Action"]}</div>
)}
</div>
)}
{slots["Header.Form"]}
</header>
)}
<div className="body">{slots["Body.Content"]}</div>
</article>
);
});TypeScript Support
The system provides full TypeScript support with excellent type inference:
- Slot configuration is inferred from the
slotsConfigargument - Component props are explicitly specified via
render<T>() - Slot availability is enforced in the render function
- Multiple slots are correctly typed as arrays
- Required slots are enforced
- Each
slots.Xis typed toReactElement<ComponentProps>based on the slot'scomponent— notReactElement<any> injectSlotPropsinfers itspropsargument from the element type, so mismatched props are caught at compile time
Example of TypeScript inference:
function TitleSlot({ level, children }: { level: 1 | 2; children?: ReactNode }) {
const Tag = `h${level}` as const;
return <Tag>{children}</Tag>;
}
// Slots are inferred, props are explicit
const Modal = createComponentWithSlots({
Title: { component: TitleSlot },
Body: {},
Actions: { multiple: true }
}).render<{ isOpen: boolean; onClose: () => void }>(
// slots will have proper typing based on configuration:
// - Title: ReactElement<{ level: 1 | 2; children?: ReactNode }> | null
// - Body: ReactElement<{ children?: ReactNode }> | null
// - Actions: ReactElement<{ children?: ReactNode }>[] (because multiple: true)
({ slots, isOpen, onClose }) => {
if (!isOpen) return null;
return (
<div className="modal">
{slots.Title}
{slots.Body}
<div className="actions">
{slots.Actions}
</div>
</div>
);
}
);Best Practices
- Use default wrappers for simple slots: Omit
componentwhen the slot needs no custom structure — the content renders without a wrapper element - Provide custom components only when needed: Only specify
componentwhen you need custom styling, logic, or structure - Use meaningful slot names: Names should reflect their purpose (Header, Body, Footer, etc.)
- Consider required slots: Mark slots as required when they're essential for functionality
- Provide sensible defaults: Use default content for optional slots with common patterns
- Handle non-slot children appropriately: Have a plan for how to deal with non-slot children
- Use
withPropsfor static prop binding: PreferwithPropsoverinjectSlotPropswhen the bound values never change — it keeps the render function clean and makes the contract explicit at the config level - Use
injectSlotPropsfor runtime props: Use it when a slot component needs render-time data (a specific callback, an open flag) but only a single slot needs it — simpler than a full context - Use
useSlotContextwhen multiple slots share state: If two or more slot components need to read or drive the same parent state, declare it incontextrather than threading props throughinjectSlotPropson each slot individually. Use the selector overload to keep re-renders granular.
Real-World Applications
The slots pattern is particularly useful for:
- Layout components: Cards, panels, dialogs, modals
- Complex UI components: Tabs, accordions, dashboards
- Form components: Input groups, form sections
- Data visualization: Charts with customizable legends, tooltips
- Application shells: Headers, footers, sidebars, navigation
By using the slots pattern, you can create flexible, reusable components that are easy to customize and maintain.
Migration Guide
Upgrading from v0.0.x to v1.0.0
Version 1.0.0 introduces a breaking change to improve TypeScript inference. The API now uses a curried/fluent pattern.
Old API (v0.0.x):
const Card = createComponentWithSlots<
{ className: string },
typeof slotsConfig
>(
slotsConfig,
({ slots, className }) => <div>{slots.Header}</div>
);New API (v1.0.0+):
// With custom props
const Card = createComponentWithSlots(slotsConfig)
.render<{ className: string }>(({ slots, className }) => (
<div>{slots.Header}</div>
));
// Without custom props (omit type parameter)
const Card = createComponentWithSlots(slotsConfig)
.render(({ slots }) => <div>{slots.Header}</div>);Key Changes:
createComponentWithSlotsnow takes only one parameter (the slots config)- Chain
.render<T>()to define the component (T defaults to{}) - Specify custom props via type parameter:
.render<{ className: string }>() - Omit type parameter when no custom props needed:
.render() - The
WithSlotshelper type has been removed (no longer needed)
