These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

Next.js — state management

The app uses two distinct, non-overlapping layers for state:

  1. Zustand — client-owned state that does not come from the server (session data, UI, in-progress job focus).
  2. TanStack Query — all server-derived data: fetching, caching, mutation, and invalidation.

These layers must stay separate. Do not put server data in a Zustand store, and do not put UI toggle state in a TanStack query. The boundary is explicit by design.

See also: routing | finder-builder-v2 | architecture overview


The two-layer model

┌──────────────────────────────────────────────────────┐ │ React component │ │ │ │ useAuthStore() useQuery(['finders', ws]) │ │ useUiStore() useMutation(updateFinder) │ │ useWorkspaceStore() useQuery(['process', id]) │ │ useProcessStore() │ │ │ │ ──── Zustand ──── ──── TanStack Query ──── │ │ synchronous reads async reads + cache │ │ localStorage persist invalidation on write │ │ no network calls no UI booleans │ └──────────────────────────────────────────────────────┘

Why not Redux? Zustand covers the four small client-state surfaces with zero boilerplate. TanStack Query renders Redux-style server-cache patterns obsolete — it handles optimistic updates, background refetching, and request deduplication out of the box. Adding Redux would mean maintaining a third state layer with no benefit over the existing two.


Zustand stores

All four stores live in src/stores/. Each file is a 'use client' module and exports a single hook created with create().

authStoresrc/stores/authStore.ts

Persisted to localStorage under the key 'user' (the same key the legacy Svelte app used — intentional for cross-app session continuity).

State shape:

FieldTypeNotes
userIdnumber | nullNull until authenticated
tokenstring | nullJWT; read by core.ts via getToken()
userEmailstring | null
userNamestring | null
userRolestring | null
subscriptionStatusstring | null
subscriptionTierstring | nullUsed for feature gating
trialEndsAtstring | null
paddleSubscriptionStatusstring | nullBilling lifecycle
paddleScheduledCancellationAtstring | null
paddleCanceledAtstring | null
paddleCancellationSourcestring | null
paddleLastPaymentStatusstring | null
paddleLastPaymentFailedAtstring | null
permissionsstring[]Permission slugs
isAuthenticatedbooleanDerived — always true after setUser
hasHydratedbooleanSet to true once persist middleware rehydrates from localStorage

Actions:

  • setUser(user) — sets all user fields and flips isAuthenticated to true.
  • logout() — resets to defaultUser (all nulls, isAuthenticated: false).
  • setHasHydrated(bool) — called automatically by the persist middleware’s onRehydrateStorage callback.

hasHydrated pattern: On first render the store may still be empty while localStorage is being read. Gate any auth-dependent render on hasHydrated === true to avoid flashing a logged-out state.

The API layer reads token imperatively via useAuthStore.getState().token (not as a hook) so it can be called outside React — see src/lib/api/core.ts:21.

On a 401 response, core.ts calls useAuthStore.getState().logout() then redirects to /login (line 145).


workspaceStoresrc/stores/workspaceStore.ts

Persisted to localStorage under 'active-workspace'. Holds only the currently selected workspace — roster data lives in TanStack Query.

State shape:

FieldType
activeWorkspaceIdnumber | null
activeWorkspaceNamestring | null

Actions:

  • setActiveWorkspace({ id, name }) — switches to a workspace.
  • clearActiveWorkspace() — resets both fields to null.

Workspace-aware queries: Most list endpoints accept an optional workspace_id query parameter. The helper buildWorkspaceAwareEndpoint in src/lib/api/core.ts:24–35 reads activeWorkspaceId imperatively from the store and appends it. You rarely need to call buildWorkspaceAwareEndpoint directly — the domain API functions (getFinders, getLocations, getProcesses) call it for you.

// src/lib/api/core.ts:24 export function buildWorkspaceAwareEndpoint(endpoint: string, workspaceId?: number | null) { const resolvedWorkspaceId = workspaceId === undefined ? useWorkspaceStore.getState().activeWorkspaceId : workspaceId; // ... }

💡 Tip: Pass an explicit workspaceId argument to API functions when you need to fetch data for a workspace other than the active one (e.g., a workspace switcher preview). Pass null to fetch without any workspace filter.


uiStoresrc/stores/uiStore.ts

Not persisted (no persist middleware). Controls transient UI state: slide-over panels and toast alerts. There is no isModalOpen boolean or separate modal registry — everything funnels through activePanel.

State shape:

FieldTypeNotes
activePanelPanelType | nullThe currently open panel; null means no panel
panelDataPanelData | nullArbitrary payload passed to the open panel (e.g., { id: 42 })
alertsAlert[]Auto-removed after duration ms

Actions:

  • openPanel(panel, data?) — opens a panel by type, optionally with data.
  • closePanel() — clears both activePanel and panelData.
  • addAlert(message, type?, duration?) — adds a toast; defaults to type: 'success', duration: 4000 ms. Auto-schedules removal via setTimeout.
  • removeAlert(id) — removes a single alert by its generated ID.

Error surfacing convention (from AGENTS.md): Surface API errors to the user via addAlert(message, 'error'). Do not use local useState error state or console.error as a substitute for user-facing feedback.

// Typical error-handling pattern const mutation = useMutation({ mutationFn: (data) => updateFinder(id, data), onError: (err) => addAlert(err.message, 'error'), });

processStoresrc/stores/processStore.ts

Not persisted. Manages the global process-monitoring tray UI: whether it is open, which process is focused, and whether there is an in-flight import batch waiting to be resumed.

State shape:

FieldTypeNotes
isTrayOpenbooleanControls visibility of the background-jobs tray
focusedProcessIdnumber | nullWhich process the tray is currently showing in detail
resumeImportBatchIdnumber | nullSet when a CSV import needs a mid-run user decision

Actions:

  • setTrayOpen(open) — sets tray visibility directly.
  • toggleTray() — flips isTrayOpen.
  • focusProcess(processId) — sets focusedProcessId and automatically opens the tray when processId is non-null; closes when null.
  • setResumeImportBatchId(batchId) — stores a batch ID that requires user review before proceeding.

This store holds only UI focus state. The actual process data (status, events, progress) is server state managed by useProcessMonitor — see below.


TanStack Query

QueryClient setup — src/providers/Providers.tsx

The QueryClient is instantiated once per browser session inside a useState initializer, preventing re-creation on re-render. It wraps the entire app via QueryClientProvider.

// src/providers/Providers.tsx:8–18 const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes retry: 1, }, }, }) );

Global defaults:

  • staleTime: 5 min — data is considered fresh for 5 minutes; no background refetch within that window.
  • retry: 1 — failed queries retry once before entering error state.

These defaults are conservative. Override per-query if you need shorter staleness (e.g., real-time process status) or zero retries (e.g., auth checks).


Query key conventions

Query keys follow a [resource, identifier?] tuple pattern. Consistency matters because invalidateQueries uses prefix matching — an invalidation on ['finders'] clears every ['finders', ...] entry.

DataQuery keySource
All finders (workspace-scoped)['finders']AGENTS.md example
Single finder['finder', id]AGENTS.md example
All locations['locations']convention (mirrors finders)
All workspaces['workspaces']convention
Process by ID['process', processId]src/hooks/useProcessMonitor.ts:17
Process events['process-events', processId]src/hooks/useProcessMonitor.ts:26

⚠️ Warning: There is no central query-key registry file. Keys are defined inline at the call site. If you add a new resource, follow the [noun, id?] pattern and search the codebase before creating a new key to avoid near-duplicate entries ('finder' vs 'finders' are both valid but serve different invalidation scopes).


Mutation and invalidation pattern

All write operations use useMutation. The canonical pattern from AGENTS.md:

const mutation = useMutation({ mutationFn: (data) => updateFinder(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['finder', id] }); queryClient.invalidateQueries({ queryKey: ['finders'] }); }, });

Rules:

  1. Always invalidate in onSuccess, not optimistically, unless you have a specific UX reason.
  2. Invalidate both the collection and the individual resource when updating an item — list views and detail views may both be mounted.
  3. Use addAlert from useUiStore for user-facing feedback on success and onError.

The useQueryClient() hook gives you access to the shared client instance from any component.


Workspace-aware queries

Most API functions accept an optional workspaceId parameter that is forwarded to buildWorkspaceAwareEndpoint. When you write a useQuery for a workspace-scoped resource, include the workspace ID in the query key so that switching workspaces triggers a cache miss and fresh fetch:

const { activeWorkspaceId } = useWorkspaceStore(); const { data: finders } = useQuery({ queryKey: ['finders', activeWorkspaceId], queryFn: () => getFinders(activeWorkspaceId), });

💡 Tip: Including activeWorkspaceId in the query key is the correct pattern even when getFinders would resolve the ID internally. The key controls cache identity — without it, switching workspaces returns cached data from the previous workspace.


Special case: useProcessMonitor

src/hooks/useProcessMonitor.ts is the most complex piece of state coordination in the codebase. It bridges TanStack Query (polling) with a manual Server-Sent Events (SSE) stream.

Exported hooks:

  • useProcess(processId) — polls ['process', processId] with adaptive interval: 3 seconds while status is queued or running, disabled otherwise (line 21).
  • useProcessEvents(processId) — one-shot fetch of ['process-events', processId].
  • useProcessMonitor(processId) (also exported as useProcessStream) — the full monitor: combines polling + SSE + local event accumulation.

How useProcessMonitor works:

  1. Calls useProcess to get polled process status.
  2. On mount (or processId change), hydrates local events state from a one-shot getProcessEvents call. Clears events on ID change.
  3. Opens an SSE connection to processes/{id}/stream?after_id={lastEventId} using a Bearer token from authStore. Only opens when the process is queued or running.
  4. Parses SSE frames, deduplicates by event.id, appends to local events state.
  5. When an event carries a terminal status (waiting_review, completed, failed, cancelled), calls queryClient.invalidateQueries({ queryKey: ['process', processId] }) to force the polling query to refetch and update the process object (line 122).
  6. SSE connection is torn down via AbortController when processId changes or the component unmounts.

Return value:

{ process: Process | null, isLoading: boolean, events: ProcessEvent[], latestEvent: ProcessEvent | null, refetch: () => void, }

useProcessStore (isTrayOpen, focusedProcessId) controls which process ID is passed to useProcessMonitor. The store owns the UI focus; the hook owns the data.


Finder Builder: local history reducer (not global store)

The Finder Builder (finder-builder-v2) manages its own undo/redo state using a useReducer local to the builder component — it does not use any of the four Zustand stores and does not push to TanStack Query until the user explicitly saves.

This is intentional: undo/redo semantics (SET/UNDO/REDO actions, a bounded history stack) are specific to the builder session and should not leak into global state or the server cache. See finder-builder-v2 for the full dispatch and history pattern.

⚠️ Warning: Do not move the history reducer into a Zustand store or into uiStore. That would make builder history globally accessible and would couple the builder’s ephemeral edit state to the page lifecycle.