Skip to main content

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

LayerLibraryFilesResponsibility
FSMsXState v5machines/*.tsFinite state machines for modal UI flows
Reactive UI state@xstate/store v3stores/layout.tsPanel sizes, active panels, layout state
Persisted preferencesZustand v5stores/typography.ts, navigation.ts, theme.tsUser settings written to localStorage
Async server dataTanStack Query v5hooks/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 selection
  • verseSelected -- 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 open
  • panelSizes -- width/height of each panel
  • focusedPanelId -- 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.

StoreKeysPurpose
typography.tsfontSize, density, redLetterToggleReading display preferences
navigation.tssidebarCollapsed, readerModeNavigation chrome preferences
theme.tsmode (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.

  1. Plugins must not import stores directly. Use GospeLibAPI.state instead (defined in lib/plugin-host/api.ts). This keeps plugin boundaries clean and enables testing plugins in isolation.

  2. Zustand is for user preferences only. No derived state, no computed values, no server data.

  3. XState machines are created once at startup. Call createActor(machine).start() at the module level. Do not create new actors per render.

  4. @xstate/store instances are module-level singletons. Import layoutStore directly -- do not wrap it in React context or create it inside components.

  5. TanStack Query owns all server data. Never put API responses in Zustand or @xstate/store. Never manually mutate the query cache -- use invalidateQueries to 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.