Client State Management with Zustand
Why this matters
Not all state is the same kind of problem. Server state (data fetched from an API) has its own lifecycle: it goes stale, needs caching, can be refetched. TanStack Query handles that. Client/UI state (is the sidebar collapsed? what theme is active? what is this editor session's current upload ID?) has no server lifecycle — it belongs to the user's interaction with the interface.
Zustand is chosen for client/UI state because:
- It has no boilerplate: you define state and actions in one place.
- Selectors are granular: a component that reads
themeonly re-renders whenthemechanges — not when any unrelated state changes. - Middleware (
persist,immer,devtools) compose cleanly without requiring a different mental model.
Two problems Zustand solves that React useState can't:
- State that must survive component unmounts (sidebar collapse, scroll position, theme preference).
- State shared between distant parts of the tree, without lifting it all the way up to
App.tsxand prop-drilling it down.
pnpm add zustand immerPattern 1 — Global singleton store with create + persist
Use this for app-wide UI state that should survive page navigations and optionally browser refreshes: theme preference, sidebar visibility, user preferences.
src/shared/stores/use-theme-store.ts:
import { create } from "zustand";
import { persist } from "zustand/middleware";
type Theme = "light" | "dark" | "system";
type ThemeStore = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
export const useThemeStore = create<ThemeStore>()(
persist(
(set) => ({
theme: "system",
setTheme: (theme) => set({ theme }),
}),
{ name: "theme-storage" },
),
);Consume anywhere with a narrow selector — only subscribe to the slice of state you actually need:
// Only re-renders when `theme` changes — not when any other store value changes
const theme = useThemeStore((s) => s.theme);
const setTheme = useThemeStore((s) => s.setTheme);Why the double-call curried form create<ThemeStore>()(...) ?
When you add middleware like persist or immer, TypeScript can't infer the full generic chain from a single call. The curried form create<T>() lets TypeScript first bind the state type, then apply the middleware, keeping the types correct throughout.
Pattern 2 — Per-instance store via context
Use this for state scoped to a specific entity or subtree — an editor session, a modal instance, a drag-and-drop zone. If you used a global singleton here, two instances of the same component would clobber each other's state.
The pattern: a factory creates one store instance per entity ID, a React Context holds it, and a typed selector hook reads it.
src/features/editor/stores/use-editor-session.ts:
import { createContext, use } from "react";
import { createStore, useStore } from "zustand";
import { devtools, persist, type StorageValue } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
// 1. Split state and actions — intersect into the store type.
// This separation makes it easy to partialize persist (state only, never actions).
export type EditorSessionState = {
sessionId: string;
uploadId: string;
fileName: string | null;
hasArtifacts: boolean;
};
export type EditorSessionActions = {
setEditorSession: (
values: Pick<
EditorSessionState,
"sessionId" | "uploadId" | "hasArtifacts" | "fileName"
>,
) => void;
setHasArtifacts: (hasArtifacts: boolean) => void;
reset: (uploadId: string) => void;
};
export type EditorSessionStore = EditorSessionState & EditorSessionActions;
// 2. Default-state factory — extracted so `reset()` can reuse it.
export function createDefaultEditorSessionState(
sessionId: string,
uploadId?: string,
): EditorSessionState {
return {
sessionId,
uploadId: uploadId ?? crypto.randomUUID(),
fileName: null,
hasArtifacts: false,
};
}
// 3. Factory that builds one store instance per sessionId.
export function createEditorSessionStore(sessionId: string) {
// Guard: clear stale persisted state that belongs to a different entity.
// Without this, navigating from session A to session B would load session A's data.
try {
const item = localStorage.getItem("editor.session") || "";
const state = JSON.parse(item) as StorageValue<EditorSessionState>;
if (state.state.sessionId !== sessionId) {
localStorage.removeItem("editor.session");
}
} catch {
localStorage.removeItem("editor.session");
}
return createStore<EditorSessionStore>()(
devtools(
persist(
immer((set) => ({
...createDefaultEditorSessionState(sessionId),
setEditorSession: (editorSession) =>
set((state) => {
state.sessionId = editorSession.sessionId;
state.uploadId = editorSession.uploadId;
state.fileName = editorSession.fileName;
state.hasArtifacts = editorSession.hasArtifacts;
}),
setHasArtifacts: (hasArtifacts) =>
set((state) => {
state.hasArtifacts = hasArtifacts;
}),
reset: (uploadId) =>
set(() => createDefaultEditorSessionState(sessionId, uploadId)),
})),
{
name: "editor.session",
// Persist only state, never actions — actions are recreated from the factory.
partialize: (state): EditorSessionState => ({
sessionId: state.sessionId,
uploadId: state.uploadId,
fileName: state.fileName,
hasArtifacts: state.hasArtifacts,
}),
},
),
{ name: `editor-session-${sessionId}` },
),
);
}
// 4. A context holds one store instance.
export const EditorSessionContext = createContext<
ReturnType<typeof createEditorSessionStore>
>(null!);
// 5. Typed selector hook — throws if the provider is missing (not silently returns undefined).
export function useEditorSession<T>(
selector: (state: EditorSessionStore) => T,
): T {
const store = use(EditorSessionContext);
if (!store) throw new Error("Missing EditorSessionContext.Provider in the tree");
return useStore(store, selector);
}The provider creates the store once via useState initializer (so it's only built once per mount) and renders the context. src/features/editor/stores/editor-session-provider.tsx:
import { useState } from "react";
import {
createEditorSessionStore,
EditorSessionContext,
} from "./use-editor-session";
export function EditorSessionProvider({
sessionId,
children,
}: {
sessionId: string;
children: React.ReactNode;
}) {
const [store] = useState(() => createEditorSessionStore(sessionId));
return (
<EditorSessionContext value={store}>{children}</EditorSessionContext>
);
}Consume with a narrow selector:
const fileName = useEditorSession((s) => s.fileName);
const setHasArtifacts = useEditorSession((s) => s.setHasArtifacts);Conventions checklist
Copy these patterns in every new store:
- Split
…Stateand…Actionstypes — intersect into…Store. This makes thepartializeoption type-safe (persist only…State). createDefault…State()factory — extracted soreset()can call it, and tests can build initial state without a full store.immermiddleware — actions mutate draft state directly (Immer handles immutability). No need for spread operators.persistwith explicitpartialize— persists only state, never actions. Actions are always recreated by the factory.- Stale-state guard in the factory — before restoring persisted state, check the entity ID matches. Clear it if not.
devtoolsas the outermost wrapper — wrappingpersistmeans Redux DevTools shows named actions. Give each instance a uniquename.- Throw in the selector hook if no provider — fail fast with a clear message rather than silently returning undefined.
Verify
pnpm run typecheckRender two EditorSessionProviders with different sessionIds. Confirm their state is independent — changing hasArtifacts in one provider should not affect the other.