Skip to content

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

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// ✅ 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

typescript
// 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

typescript
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

typescript
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

typescript
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:

typescript
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

typescript
// Handle rehydration in app initialization
const hasHydrated = useStore.persist.hasHydrated();

if (!hasHydrated) {
  return <LoadingScreen />;
}

Migration Strategy

typescript
// 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

typescript
// Get specific slice
const actors = useStore((s) => s.cast.actors);

// Get computed value
const actorCount = useStore((s) => s.cast.actors.length);

Memoized Selectors

typescript
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

typescript
// 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

typescript
// 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

typescript
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

typescript
// Store actions are visible in DevTools
devtools(
  (set) => ({ /* state */ }),
  { name: 'RunSheetStore' }
)

Console Logging

typescript
// 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)