Per-Instance Zustand Store: Three Independent Calendars on One Page
Per-Instance Zustand Store: Three Independent Calendars on One Page
Zustand is designed for global singletons. But a component library needs isolated instances. Here's the factory pattern that makes both work.
Zustand's standard usage is a global singleton: create() once, useStore() anywhere. This is perfect for dashboard state, auth, and global UI. But when you're building a component library, you face a different requirement: two Calendars side by side on the same page, each with its own state. A global store makes this impossible.
kui-react's Calendar and RichTextEditor solve this with a per-instance store factory pattern.
The Problem with Global State
// ❌ Global store — all instances share the same state
const useCalendarStore = create<CalendarStore>(...);
// Two calendars in a dashboard
<Calendar /> {/* reads store */}
<Calendar /> {/* reads the same store — state collides */}
Changing the month in the first calendar changes it in the second. A drag operation shows in both. Context API could isolate instances, but then you lose Zustand's selector-based performance optimization.
The Factory: createCalendarStore()
modules/app/Calendar/store.ts creates a new store per instance:
export function createCalendarStore(initial: { date: Date; view: View }) {
return create<CalendarStore>((set) => ({
date: initial.date,
view: initial.view,
popover: EMPTY_POPOVER,
drag: IDLE_DRAG,
calendars: [],
hiddenCalendarIds: new Set<string>(),
setDate: (d) => set({ date: d }),
setView: (v) => set({ view: v }),
openPopover: (event, anchorRect) => set({ popover: { event, anchorRect } }),
closePopover: () => set({ popover: EMPTY_POPOVER }),
setDrag: (d) => set({ drag: d }),
toggleCalendar: (id) =>
set((s) => {
const next = new Set(s.hiddenCalendarIds);
next.has(id) ? next.delete(id) : next.add(id);
return { hiddenCalendarIds: next };
}),
}));
}
create() is called every time the factory is invoked. Each call produces a new, independent store. Not a singleton.
Distribution via Context
The store instance needs to reach sub-components. Context handles this:
type CalendarStoreHook = UseBoundStore<StoreApi<CalendarStore>>;
const CalendarStoreContext = createContext<CalendarStoreHook | null>(null);
export function CalendarStoreProvider({
store,
children,
}: {
store: CalendarStoreHook;
children: React.ReactNode;
}) {
return createElement(CalendarStoreContext.Provider, { value: store }, children);
}
export function useCalStore<T>(selector: (s: CalendarStore) => T): T {
const store = useContext(CalendarStoreContext);
if (!store) throw new Error('useCalStore: missing CalendarStoreProvider');
return store(selector);
}
export function useCalStoreApi(): StoreApi<CalendarStore> {
const store = useContext(CalendarStoreContext);
if (!store) throw new Error('useCalStoreApi: missing CalendarStoreProvider');
return store;
}
useCalStore(selector) preserves Zustand's selector-based performance — only the selected slice triggers a re-render. useCalStoreApi() gives imperative access for event handlers that need to read or write the store outside of render.
Mounting the Store
export function Calendar({
tasks, view, date, onEventCreate,
}: CalendarProps) {
const [store] = useState(() =>
createCalendarStore({
date: date ?? new Date(),
view: view ?? 'month',
})
);
return (
<CalendarStoreProvider store={store}>
<CalendarToolbar />
<CalendarBody />
</CalendarStoreProvider>
);
}
useState(() => createCalendarStore(...)) — the lazy initializer runs only on first render. The store is created once and stable for the component's lifetime. [store] destructuring signals that the store reference never changes.
Sub-component Consumption
function CalendarToolbar() {
const view = useCalStore((s) => s.view);
const date = useCalStore((s) => s.date);
const setView = useCalStore((s) => s.setView);
return (
<div>
<button onClick={() => setView('month')}>Month</button>
<button onClick={() => setView('week')}>Week</button>
<span>{formatDate(date, view)}</span>
</div>
);
}
function DayView() {
const drag = useCalStore((s) => s.drag);
// Only re-renders when drag changes — CalendarToolbar is unaffected
}
Selector granularity means each sub-component re-renders only when its specific slice changes. Calendar drag state updating doesn't re-render the toolbar.
The Same Pattern in RichTextEditor
modules/app/RichTextEditor/store.ts uses an identical approach:
export function createRichTextEditorStore(initialHtml: string) {
return create<RteStore>((set, get) => ({
html: initialHtml,
chars: 0,
words: 0,
ready: false,
mention: { open: false, query: '', trigger: -1, pos: null, idx: 0 },
slash: { open: false, query: '', trigger: -1, pos: null, idx: 0 },
}));
}
export const RichTextEditor = forwardRef<...>(function RichTextEditor(props, ref) {
const store = useMemo(
() => createRichTextEditorStore(props.value ?? props.defaultValue ?? ''),
[] // eslint-disable-line react-hooks/exhaustive-deps
);
return (
<RichTextEditorStoreProvider store={store}>
<Inner {...props} _store={store} ref={ref} />
</RichTextEditorStoreProvider>
);
});
useMemo(..., []) creates the store once at mount. The eslint-disable comment is intentional: props.value shouldn't be in the dependency array. The store takes the initial value; subsequent controlled updates sync through useEffect, not store recreation.
Controlled Prop Sync
After the store is created, external prop changes sync via useEffect:
useEffect(() => {
if (date) store.getState().setDate(date);
}, [date, store]);
useEffect(() => {
if (view) store.getState().setView(view);
}, [view, store]);
Controlled component pattern: when props change, the store updates. When the store changes, callbacks fire (onDateChange, onViewChange). The two-way binding stays in sync without recreating the store.
Trade-off
Every Calendar instance carries its own Zustand store. Memory cost is real — 50 simultaneous Calendar instances means 50 stores. In practice this rarely matters, but animation-heavy list scenarios deserve attention.
Context introduces a Provider requirement that global Zustand doesn't have. Every Calendar consumer must be wrapped in <CalendarStoreProvider>. For an internal component library this is hidden inside the Calendar root — external users don't see it. For a library distributed as a package, it's implicit.
The alternative — useReducer + Context — avoids Zustand entirely but loses selector-based re-render optimization. For high-frequency state like drag, which can fire dozens of updates per second, the performance difference is measurable.
Business Impact
A resource planning dashboard with three Calendars side by side — one per department. With global state, this is impossible. With per-instance stores, each calendar navigates months, creates events, and drags items independently.
Form builders with multiple RichTextEditor fields: each has its own autosave key, mention state, and undo history. They coexist without interference.
Something to Try
If your Zustand store contains component-specific state (currentCalendarDate, editorContent_1, editorContent_2 — namespaced by key), consider migrating to per-instance stores. The createXStore() factory + Context + useXStore(selector) triad can be set up in a day. The gain: global store stays clean, instances are fully isolated, adding a new instance is zero-config.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox