Next.js — state management
The app uses two distinct, non-overlapping layers for state:
- Zustand — client-owned state that does not come from the server (session data, UI, in-progress job focus).
- 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().
authStore — src/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:
| Field | Type | Notes |
|---|---|---|
userId | number | null | Null until authenticated |
token | string | null | JWT; read by core.ts via getToken() |
userEmail | string | null | |
userName | string | null | |
userRole | string | null | |
subscriptionStatus | string | null | |
subscriptionTier | string | null | Used for feature gating |
trialEndsAt | string | null | |
paddleSubscriptionStatus | string | null | Billing lifecycle |
paddleScheduledCancellationAt | string | null | |
paddleCanceledAt | string | null | |
paddleCancellationSource | string | null | |
paddleLastPaymentStatus | string | null | |
paddleLastPaymentFailedAt | string | null | |
permissions | string[] | Permission slugs |
isAuthenticated | boolean | Derived — always true after setUser |
hasHydrated | boolean | Set to true once persist middleware rehydrates from localStorage |
Actions:
setUser(user)— sets all user fields and flipsisAuthenticatedtotrue.logout()— resets todefaultUser(all nulls,isAuthenticated: false).setHasHydrated(bool)— called automatically by the persist middleware’sonRehydrateStoragecallback.
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).
workspaceStore — src/stores/workspaceStore.ts
Persisted to localStorage under 'active-workspace'. Holds only the currently selected workspace — roster data lives in TanStack Query.
State shape:
| Field | Type |
|---|---|
activeWorkspaceId | number | null |
activeWorkspaceName | string | null |
Actions:
setActiveWorkspace({ id, name })— switches to a workspace.clearActiveWorkspace()— resets both fields tonull.
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.
uiStore — src/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:
| Field | Type | Notes |
|---|---|---|
activePanel | PanelType | null | The currently open panel; null means no panel |
panelData | PanelData | null | Arbitrary payload passed to the open panel (e.g., { id: 42 }) |
alerts | Alert[] | Auto-removed after duration ms |
Actions:
openPanel(panel, data?)— opens a panel by type, optionally with data.closePanel()— clears bothactivePanelandpanelData.addAlert(message, type?, duration?)— adds a toast; defaults totype: 'success',duration: 4000ms. Auto-schedules removal viasetTimeout.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'),
});processStore — src/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:
| Field | Type | Notes |
|---|---|---|
isTrayOpen | boolean | Controls visibility of the background-jobs tray |
focusedProcessId | number | null | Which process the tray is currently showing in detail |
resumeImportBatchId | number | null | Set when a CSV import needs a mid-run user decision |
Actions:
setTrayOpen(open)— sets tray visibility directly.toggleTray()— flipsisTrayOpen.focusProcess(processId)— setsfocusedProcessIdand automatically opens the tray whenprocessIdis non-null; closes whennull.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.
| Data | Query key | Source |
|---|---|---|
| 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:
- Always invalidate in
onSuccess, not optimistically, unless you have a specific UX reason. - Invalidate both the collection and the individual resource when updating an item — list views and detail views may both be mounted.
- Use
addAlertfromuseUiStorefor user-facing feedback on success andonError.
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 isqueuedorrunning, disabled otherwise (line 21).useProcessEvents(processId)— one-shot fetch of['process-events', processId].useProcessMonitor(processId)(also exported asuseProcessStream) — the full monitor: combines polling + SSE + local event accumulation.
How useProcessMonitor works:
- Calls
useProcessto get polled process status. - On mount (or
processIdchange), hydrates localeventsstate from a one-shotgetProcessEventscall. Clears events on ID change. - Opens an SSE connection to
processes/{id}/stream?after_id={lastEventId}using a Bearer token fromauthStore. Only opens when the process isqueuedorrunning. - Parses SSE frames, deduplicates by
event.id, appends to localeventsstate. - When an event carries a terminal status (
waiting_review,completed,failed,cancelled), callsqueryClient.invalidateQueries({ queryKey: ['process', processId] })to force the polling query to refetch and update the process object (line 122). - SSE connection is torn down via
AbortControllerwhenprocessIdchanges 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.