A lightweight library for customizing subcomponents in React
This package provides a lightweight approach that allows your component users to easily customize nested subcomponents. The concept is inspired by Material UI's slots pattern.
- Simple API with
useSlothook andSlotcomponent - Compatible with React Compiler
- TypeScript support with full type inference
- Optimized with
useMemoanduseCallbackfor performance - Minimal bundle size with zero dependencies
- Development mode warnings for debugging
npm install @zemd/react-slottable
pnpm add @zemd/react-slottable
yarn add @zemd/react-slottableThe core concept of this library is the slot. A slot is a part of a component that can be overridden or customized by the consumer. Instead of creating numerous props to customize nested components, you can divide your component into slots and let users provide their own implementations.
Here is how to create a simple Button component with startDecorator and endDecorator slots:
import { type PropsWithSlots, useSlot } from "@zemd/react-slottable";
// Define your component props with slots
type ButtonProps = PropsWithSlots<
React.PropsWithChildren<{
fullWidth?: boolean;
disabled?: boolean;
size?: "sm" | "md" | "xl";
variant?: "solid" | "outlined";
color?: "primary" | "secondary";
className?: string;
}>,
["startDecorator", "endDecorator"]
>;
// Optional: define a default component for a slot
const DefaultDecorator: React.FC<{ className?: string }> = ({ className }) => {
return <div className={className}>Default decorator</div>;
};
export const Button: React.FC<ButtonProps> = (props) => {
// useSlot returns a render function (React Compiler compatible)
const renderStartDecorator = useSlot("startDecorator", props, {
slot: DefaultDecorator, // provide a default component
});
const renderEndDecorator = useSlot("endDecorator", props);
return (
<button className={props.className}>
{renderStartDecorator({ className: "decorator-class" })}
{props.children}
{renderEndDecorator({})}
</button>
);
};For developers who prefer a more declarative JSX syntax, the Slot component provides an alternative to the useSlot hook:
import { type PropsWithSlots, Slot } from "@zemd/react-slottable";
type AlertProps = PropsWithSlots<{ type: "info" | "error"; title: string; message: string }, ["icon", "title", "message"]>;
export const Alert: React.FC<AlertProps> = (props) => {
return (
<div role="alert">
<Slot name="icon" parentProps={props} default="span">
{props.type === "info" ? "βΉοΈ" : "β"}
</Slot>
<Slot name="title" parentProps={props} default="div">
{props.title}
</Slot>
<Slot name="message" parentProps={props} default="div">
{props.message}
</Slot>
</div>
);
};Consumers can now customize the slots by providing their own components:
const MyCustomDecorator: React.FC = () => {
return <span>Custom decorator</span>;
};
export function App(): React.JSX.Element {
return (
<Button
slots={{
endDecorator: MyCustomDecorator,
}}
slotProps={{
startDecorator: {
className: "custom-class",
},
}}
className="my-button"
>
Click me
</Button>
);
}A hook that returns a memoized render function for a given slot.
const renderSlot = useSlot(name, props, options);Parameters:
name- The name of the slotprops- The component props containingslotsandslotPropsoptions- Optional configuration objectslot- Default component to render if no slot is provided...extraProps- Additional props to pass to the slot (highest priority)
Returns:
A memoized render function (SlotRenderFunction) that accepts props and returns the rendered slot.
A JSX component alternative to the useSlot hook.
<Slot name="header" parentProps={props} default="div" className="header">
{children}
</Slot>Props:
name- The name of the slot to renderparentProps- The parent component props containingslotsandslotPropsdefault- Default component to render if no slot is providedchildren- Children to render inside the slot...rest- Additional props passed to the slot component
A type helper that extends your component props with slots and slotProps using a simple array of slot names.
type MyComponentProps = PropsWithSlots<{ title: string }, ["header", "footer"]>;This adds the following optional props to your component:
slots- An object mapping slot names to custom componentsslotProps- An object mapping slot names to props passed to each slot
A type helper for advanced use cases that provides better TypeScript inference for slot-specific props.
type ButtonSlots = {
startDecorator: React.FC<{ icon?: string; className?: string }>;
endDecorator: React.FC<{ label?: string }>;
};
type ButtonProps = PropsWithTypedSlots<{ disabled?: boolean }, ButtonSlots>;
// Now slotProps will have proper type inference:
// slotProps.startDecorator accepts { icon?: string; className?: string }
// slotProps.endDecorator accepts { label?: string }The type of the render function returned by useSlot.
import type { SlotRenderFunction } from "@zemd/react-slottable";
type MyRenderFn = SlotRenderFunction<typeof MyComponent>;When a slot is rendered:
- If the user provides a custom component via
slots, it is used - Otherwise, the default component from
options.slotis used - If neither is provided,
nullis returned (nothing is rendered)
Props are merged in the following order (later values override earlier ones):
- Props passed directly to the render function
- Props from
slotProps - Extra props from the
optionsobject
The library works seamlessly with external state management. Here's an example using useSyncExternalStore:
import { useSyncExternalStore } from "react";
import { useSlot, type PropsWithSlots } from "@zemd/react-slottable";
// Create an external store
function createCounterStore(initialValue = 0) {
let state = { count: initialValue };
const listeners = new Set<() => void>();
return {
getSnapshot: () => state,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
increment: () => {
state = { count: state.count + 1 };
listeners.forEach((l) => l());
},
};
}
const store = createCounterStore(0);
// Counter component with customizable slots
type CounterProps = PropsWithSlots<{ min?: number; max?: number }, ["display", "incrementButton"]>;
const Counter: React.FC<CounterProps> = (props) => {
const { count } = useSyncExternalStore(store.subscribe, store.getSnapshot);
const renderDisplay = useSlot("display", props, { slot: "span" });
const renderButton = useSlot("incrementButton", props, { slot: "button" });
return (
<div>
{renderDisplay({ children: count })}
{renderButton({
onClick: store.increment,
disabled: count >= (props.max ?? Infinity),
children: "+",
})}
</div>
);
};
// Multiple counters can share the same store with different slot implementations
function App() {
return (
<>
<Counter />
<Counter
slots={{
display: ({ children }) => <strong>{children}</strong>,
}}
/>
</>
);
}The library is optimized for performance:
- Memoized slot resolution - The resolved slot component is memoized with
useMemo - Memoized render function - The render function is memoized with
useCallback - Stable references - Extra props and slot props are memoized to prevent unnecessary re-renders
This project is licensed under the Apache License 2.0.