Skip to content

Per-Instance Zustand Store: Three Independent Calendars on One Page

8/18/2025Mobile DevelopmentKUIreact6 min read

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 Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox