State Management
The GospeLib web app splits state across four layers. Each layer owns a specific kind of data, uses a specific library, and has strict boundary rules. Mixing layers (for example, putting server data in Zustand) is a bug.
Layers at a Glance
| Layer | Library | Files | Responsibility |
|---|---|---|---|
| FSMs | XState v5 | machines/*.ts | Finite state machines for modal UI flows |
| Reactive UI state | @xstate/store v3 | stores/layout.ts | Panel sizes, active panels, layout state |
| Persisted preferences | Zustand v5 | stores/typography.ts, navigation.ts, theme.ts | User settings written to localStorage |
| Async server data | TanStack Query v5 | hooks/useChapter.ts, etc. | Scripture chapters, lexicon, API responses |
When to Use Each Layer
XState v5 -- Finite State Machines
Location: apps/web/machines/*.ts
Use XState when you need explicit states, transitions, and guards -- situations where the set of valid states is finite and transitions between them must be controlled.
Good candidates:
- Modal UI flows with branching logic
- Multi-step interactions (selection, annotation, confirmation)
- Sequences that must be impossible to interrupt mid-flow
Example: Study Context Machine
The studyContextMachine drives the Floating Toolbar:
idle --> verseSelected --> wordSelected
^ | |
| v v
+--- (dismiss) <---------(dismiss)
idle-- toolbar hidden, no selectionverseSelected-- verse-level actions available (highlight, share, cross-refs)wordSelected-- word-level actions available (Strong's lookup, morphology, interlinear)
Each state defines exactly which actions are available, making invalid states unrepresentable.
Creating a Machine
Machines are created once at app startup and live for the lifetime of the session:
import { createActor } from 'xstate';
import { studyContextMachine } from '../machines/studyContext';
const actor = createActor(studyContextMachine).start();
@xstate/store v3 -- Reactive UI State
Location: apps/web/stores/layout.ts
Use @xstate/store for reactive panel and layout state that multiple components subscribe to. This is the coordination layer for the multi-panel reader UI.
State managed here:
activePanelIds-- which panels are currently openpanelSizes-- width/height of each panelfocusedPanelId-- which panel has keyboard focus- Context panel visibility
The store is a module-level singleton. Import it directly:
import { layoutStore } from '../stores/layout';
// Read current state
const snapshot = layoutStore.getSnapshot();
const activePanels = snapshot.context.activePanelIds;
// Send events to update state
layoutStore.send({ type: 'panel.open', panelId: 'lexicon' });
layoutStore.send({ type: 'panel.resize', panelId: 'lexicon', width: 400 });
Persistence
Selected keys from @xstate/store can be persisted to localStorage for layout restoration across sessions. Larger serialized state (such as full dockview layouts) goes to Dexie (lib/db.ts) instead.
Zustand v5 -- Persisted User Preferences
Location: apps/web/stores/typography.ts, navigation.ts, theme.ts
Use Zustand for simple persisted user preferences that do not have FSM complexity. Each store maps to a narrow slice of user settings.
| Store | Keys | Purpose |
|---|---|---|
typography.ts | fontSize, density, redLetterToggle | Reading display preferences |
navigation.ts | sidebarCollapsed, readerMode | Navigation chrome preferences |
theme.ts | mode (dark / light / study / blackout) | Theme selection |
SSR Hydration
All Zustand stores use skipHydration: true to prevent SSR/client mismatch. Hydration happens client-side only:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface TypographyStore {
fontSize: number;
density: 'comfortable' | 'compact';
redLetter: boolean;
setFontSize: (size: number) => void;
setDensity: (d: 'comfortable' | 'compact') => void;
toggleRedLetter: () => void;
}
export const useTypographyStore = create<TypographyStore>()(
persist(
(set) => ({
fontSize: 18,
density: 'comfortable',
redLetter: true,
setFontSize: (fontSize) => set({ fontSize }),
setDensity: (density) => set({ density }),
toggleRedLetter: () => set((s) => ({ redLetter: !s.redLetter })),
}),
{
name: 'gospelib-typography',
storage: createJSONStorage(() => localStorage),
skipHydration: true,
},
),
);
Zustand stores are for user preferences only. Do not store derived or computed state here.
TanStack Query v5 -- Async Server Data
Location: apps/web/hooks/useChapter.ts and other hooks/use*.ts files
Use TanStack Query for all data fetched from the Content API. Scripture content is immutable, so aggressive caching is correct.
Default configuration:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 60, // 1 hour
refetchOnWindowFocus: false,
},
},
});
Example: Fetching a Chapter
import { useQuery } from '@tanstack/react-query';
import { client } from '@gospelib/sdk';
export function useChapter(bookId: string, chapter: number) {
return useQuery({
queryKey: ['chapter', bookId, chapter],
queryFn: () =>
client
.GET('/api/v1/passages/{book_id}/{chapter}', {
params: { path: { book_id: bookId, chapter: String(chapter) } },
})
.then((res) => res.data),
});
}
Invalidation
TanStack Query invalidation is the canonical way to refresh server data. Never manually set the query cache:
import { useQueryClient } from '@tanstack/react-query';
const queryClient = useQueryClient();
// Invalidate a specific chapter
queryClient.invalidateQueries({ queryKey: ['chapter', 'gen', 1] });
// Invalidate all chapters for a book
queryClient.invalidateQueries({ queryKey: ['chapter', 'gen'] });
Boundary Rules
These rules are non-negotiable. Violating them creates bugs that are hard to trace.
-
Plugins must not import stores directly. Use
GospeLibAPI.stateinstead (defined inlib/plugin-host/api.ts). This keeps plugin boundaries clean and enables testing plugins in isolation. -
Zustand is for user preferences only. No derived state, no computed values, no server data.
-
XState machines are created once at startup. Call
createActor(machine).start()at the module level. Do not create new actors per render. -
@xstate/store instances are module-level singletons. Import
layoutStoredirectly -- do not wrap it in React context or create it inside components. -
TanStack Query owns all server data. Never put API responses in Zustand or @xstate/store. Never manually mutate the query cache -- use
invalidateQueriesto trigger a refetch.
Decision Flow
When adding new state, use this decision tree:
Is it data from the server (API)?
YES --> TanStack Query
NO --> Is it a multi-step flow with explicit states?
YES --> XState v5 machine
NO --> Is it panel layout / coordination state?
YES --> @xstate/store (layout.ts)
NO --> Is it a user preference saved to localStorage?
YES --> Zustand store
NO --> React local state (useState)
Most new state ends up as either TanStack Query (server data) or React local state (ephemeral UI). New Zustand stores and XState machines are rare -- check whether an existing store or machine already covers your use case before creating a new one.