State Management Architecture
Zustand slices with Immer, Zundo, and persistence.
Overview
On Book Pro uses a single Zustand store composed of multiple feature slices with middleware for:
- Immer — Mutable state updates
- Zundo — Undo/Redo history
- Persist — LocalStorage persistence
- Devtools — Redux DevTools integration
Store Structure
Slice Composition
// src/store/useAppStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { temporal } from 'zundo';
import { immer } from 'zustand/middleware/immer';
// Feature slices are co-located with their features
import { createMetaSlice } from '../features/run-sheet/meta-slice';
import { createRunSheetSlice } from '../features/run-sheet/store';
import { createShowStructureSlice } from '../features/show-structure/store';
import { createCastSlice } from '../features/cast/store';
import { createSchedulerSlice } from '../features/scheduler/store';
import { createPropsSlice } from '../features/props/store';
import { createSoundSlice } from '../features/sound/store';
import { createProductionSlice } from '../features/run-sheet/production-slice';
import { createNoteSlice } from '../features/notes/store';
import { createFeedSlice } from '../features/feed/store';
import { createFilesSlice } from '../features/files/store';
import { createAdminSlice } from './admin-slice';
import { createCostumesSlice } from '../features/costumes/store';
import { createLightingSlice } from '../features/lighting/store';
import { createActivityLogSlice } from '../features/activity-log/store';
import { createPromptBookSlice } from '../features/prompt-book/store';
import { createAttendanceSlice } from '../features/attendance/store';
import { createPerformanceSlice } from '../features/performance/store';
export const useAppStore = create<AppState>()(
devtools(
temporal(
persist(
immer((...a) => ({
...createMetaSlice(...a),
...createRunSheetSlice(...a),
...createShowStructureSlice(...a),
...createCastSlice(...a),
...createSchedulerSlice(...a),
...createPropsSlice(...a),
...createSoundSlice(...a),
...createProductionSlice(...a),
...createNoteSlice(...a),
...createFeedSlice(...a),
...createFilesSlice(...a),
...createAdminSlice(...a),
...createCostumesSlice(...a),
...createLightingSlice(...a),
...createActivityLogSlice(...a),
...createPromptBookSlice(...a),
...createAttendanceSlice(...a),
...createPerformanceSlice(...a),
})),
{
name: 'onbook-storage',
version: 8,
// Migration and partialize config...
}
),
{ limit: 100 } // Zundo undo history limit
)
)
);Slice Pattern
Example: CastSlice (Personnel Management)
The CastSlice manages personnel (actors/crew) and their actor-character relationships:
// src/features/cast/store.ts
import { StateCreator } from 'zustand';
import { Personnel } from './types';
export interface CastState {
personnel: Personnel[];
trackingColumns: TrackingColumn[];
}
export interface CastActions {
addPersonnel: (member: Omit<Personnel, 'id'>) => void;
updatePersonnel: (id: string, updates: Partial<Personnel>) => void;
deletePersonnel: (id: string) => void;
assignCharacterToActor: (actorId: string, characterId: string) => void;
removeCharacterFromActor: (actorId: string, characterId: string) => void;
}
export const createCastSlice: StateCreator<
StoreState,
[['zustand/immer', never]],
[],
CastState & CastActions
> = (set, get) => ({
// State
personnel: [],
trackingColumns: [],
// Actions
addPersonnel: (member) =>
set((state) => {
state.personnel.push({
...member,
id: crypto.randomUUID(),
});
}),
updatePersonnel: (id, updates) =>
set((state) => {
const member = state.personnel.find((p) => p.id === id);
if (member) Object.assign(member, updates);
}),
deletePersonnel: (id) =>
set((state) => {
state.personnel = state.personnel.filter((p) => p.id !== id);
}),
// Actor-character relationship managed via showStructure.characters
assignCharacterToActor: (actorId, characterId) =>
set((state) => {
const character = state.showStructure.characters.find((c) => c.id === characterId);
if (character) {
character.actorId = actorId;
}
}),
});Example: ShowStructureSlice (Characters, Acts, Scenes)
Characters are managed in ShowStructureSlice, consolidating all show-related data:
// src/features/show-structure/store.ts
import { StateCreator } from 'zustand';
import { Character, Act, Scene, ScriptBlock } from './types';
export interface ShowStructure {
acts: Act[];
scenes: Scene[];
characters: Character[]; // Characters consolidated here
scriptBlocks?: ScriptBlock[];
}
export interface ShowStructureSliceState {
showStructure: ShowStructure;
}
export interface ShowStructureActions {
showStructureActions: {
// Character actions (single source of truth)
setCharacters: (characters: Character[]) => void;
updateCharacter: (id: string, updates: Partial<Character>) => void;
// Act/Scene actions
addAct: (name: string) => void;
addScene: (actId: string, name: string) => void;
// ... etc
};
}
export const createShowStructureSlice: StateCreator<...> = (set) => ({
showStructure: {
acts: [],
scenes: [],
characters: [], // Characters stored here
scriptBlocks: [],
},
showStructureActions: {
setCharacters: (characters) =>
set((state) => {
state.showStructure.characters = characters;
}),
updateCharacter: (id, updates) =>
set((state) => {
const char = state.showStructure.characters.find((c) => c.id === id);
if (char) Object.assign(char, updates);
}),
// ... other actions
},
});Key Architecture Decision: Characters live in showStructure.characters rather than a separate top-level array. This:
- Groups all show-structural data together (acts, scenes, characters)
- Simplifies state access patterns across the app
- Aligns with the conceptual model (characters are part of show structure)
Immer Usage
Mutable Updates
With Immer, you can write mutable code that's actually immutable:
// ✅ Good: Immer allows mutation syntax
set((state) => {
state.scenes.push(newScene);
state.scenes[0].name = 'Updated';
});
// ❌ Bad: Manual spreading (unnecessary with Immer)
set({
scenes: [...state.scenes, newScene],
});Nested Updates
// Update deeply nested state
set((state) => {
const scene = state.showStructure.scenes.find(s => s.id === sceneId);
if (scene) {
scene.characters.push(characterId);
}
});Undo/Redo with Zundo
Temporal Middleware
import { temporal } from 'zundo';
const useStore = create(
temporal(
(set) => ({ /* state */ }),
{
limit: 50, // Max undo steps
partialize: (state) => {
// Don't track meta changes in undo history
const { meta, ...rest } = state;
return rest;
},
}
)
);Using Undo/Redo
import { useStore } from '@/store';
const MyComponent = () => {
const undo = useStore.temporal.getState().undo;
const redo = useStore.temporal.getState().redo;
return (
<>
<button onClick={undo}>Undo</button>
<button onClick={redo}>Redo</button>
</>
);
};Persistence
LocalStorage Strategy
persist(
(set, get) => ({ /* slices */ }),
{
name: 'run-sheet-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
// Persist only non-ephemeral data
meta: state.meta,
runSheet: state.runSheet,
cast: state.cast,
scheduler: state.scheduler,
props: state.props,
sound: state.sound,
production: state.production,
notes: state.notes,
forum: state.forum,
// Don't persist cloud sync state or Firestore-sourced data
}),
}
)Firestore-First Exclusions
Features that use Firestore as the source of truth (via subcollection persistence) are excluded from partialize to avoid stale-data conflicts:
partialize: (state) => {
const excludeKeys = new Set([
'feedPosts', 'feedComments', // Feed (Firestore subcollections)
'lightingCues', 'followSpotCues', // Lighting (Firestore subcollections)
'followSpots', 'cueAnchors',
'customCues',
'attendanceRecords', // Attendance (Firestore subcollections)
'performance', // Performance (Firestore subcollections)
]);
// These keys are synced via dedicated onSnapshot listeners,
// not LocalStorage. Adding new Firestore-first features?
// Add their state keys to excludeKeys.
}See Cloud Sync: Subcollection Persistence Pattern for the full architecture.
Rehydration
// Handle rehydration in app initialization
const hasHydrated = useStore.persist.hasHydrated();
if (!hasHydrated) {
return <LoadingScreen />;
}Migration Strategy
// Handle schema changes
persist(
(set, get) => ({ /* state */ }),
{
name: 'run-sheet-storage',
version: 2,
migrate: (persistedState, version) => {
if (version === 1) {
// Migrate from v1 to v2
return {
...persistedState,
newField: defaultValue,
};
}
return persistedState;
},
}
)Selectors
Basic Selectors
// Get specific slice
const actors = useStore((s) => s.cast.actors);
// Get computed value
const actorCount = useStore((s) => s.cast.actors.length);Memoized Selectors
import { shallow } from 'zustand/shallow';
// Only re-render if specific fields change
const { projectName, ownerId } = useStore(
(s) => ({ projectName: s.meta.projectName, ownerId: s.meta.ownerId }),
shallow
);Custom Hooks
// src/hooks/useScenes.ts
export const useScenes = () => {
return useStore((s) => s.showStructure.scenes);
};
export const useSceneById = (sceneId: SceneId) => {
return useStore((s) =>
s.showStructure.scenes.find(scene => scene.id === sceneId)
);
};Actions Pattern
Grouped Actions
// src/store/slices/metaSlice.ts
export const createMetaSlice: StateCreator<...> = (set) => ({
// State
meta: {
projectId: null,
projectName: '',
ownerId: null,
cloudSyncEnabled: false,
},
// Actions grouped by concern
metaActions: {
setProjectId: (id) => set((state) => { state.meta.projectId = id; }),
setProjectName: (name) => set((state) => { state.meta.projectName = name; }),
enableCloudSync: () => set((state) => { state.meta.cloudSyncEnabled = true; }),
},
});Usage
const { setProjectName } = useStore((s) => s.metaActions);
setProjectName('Hamlet');Best Practices
✅ Do
- Use Immer syntax — Mutate state directly inside
set() - Create focused selectors — Only subscribe to what you need
- Group related actions — Keep actions near their state
- Use branded types — Type-safe IDs prevent bugs
❌ Don't
- Don't mutate outside set() — Immer only works inside
set() - Don't subscribe to entire store — Causes unnecessary re-renders
- Don't store derived state — Compute it in selectors
- Don't persist everything — Exclude ephemeral data
State Clearing
Logout (clearCloudSession)
The clearCloudSession() action in meta-slice.ts comprehensively resets all data slices on sign-out. This is critical because Zustand's persist middleware will re-serialize any remaining state back to localStorage.
What it clears:
- Cloud metadata (projectId, member, sync flags)
- All 14+ data slices (showStructure, cast, props, runSheet, scheduler, sound, costumes, notes, feed, production, lighting, promptBook, attendance, activityLog)
- Project metadata (title, director, logos)
- UI state (activeTab, activeWorkspace)
Adding a New Slice?
When adding a new feature slice, you must also add its clearing logic to both clearCloudSession() and setProject() in meta-slice.ts. Forgetting this causes data leakage between sessions or projects.
Project Switching (setProject)
The setProject() action resets all data slices (same list as clearCloudSession) before loading a new project's data. This prevents data from one project bleeding into another.
See Authentication: Logout Flow for the critical ordering requirement when combining clearCloudSession with localStorage clearing.
Debugging
Redux DevTools
// Store actions are visible in DevTools
devtools(
(set) => ({ /* state */ }),
{ name: 'RunSheetStore' }
)Console Logging
// Debug specific slice updates
set((state) => {
console.log('Before:', state.scenes);
state.scenes.push(newScene);
console.log('After:', state.scenes);
});Further Reading
Last updated: February 20, 2026 (Added missing slices, Firestore-first exclusions, and state clearing documentation)