Skip to main content

State Management

Lumie uses different tools for different state tiers rather than one global store.

State Tiers

State typePrimary toolCurrent usage
Server stateTanStack QuerySession, lists, detail views, and most CRUD-backed screens
Form stateReact Hook FormLogin, signup, onboarding, settings, CRUD dialogs, and multi-step forms
Shared client UI stateZustandShared auth modal state and server-hydrated public tenant context
Route stateNext.js search paramsList filters, sorting, pagination, modal entry params
Local ephemeral UI stateReact component stateDraft input values, dialog state, pending actions, view toggles

Source Paths

State boundarySource path
Query defaults and server/browser client splitlumie-frontend/src/shared/lib/query-client.ts
Root Query providerlumie-frontend/src/shared/providers/QueryProvider.tsx
Orval fetch bridgelumie-frontend/src/shared/api/orval-mutator.ts
Shared API request and refresh retrylumie-frontend/src/shared/api/base.ts
Server auth statelumie-frontend/src/entities/session/api/getServerUser.ts
Session cache and shared auth accessor bridgelumie-frontend/src/shared/lib/sessionCache.ts, lumie-frontend/src/shared/api/sessionAccessor.ts
Auth redirect reason mappinglumie-frontend/src/shared/lib/authRedirectReason.ts
Client auth modal storelumie-frontend/src/shared/providers/AuthModalProvider.tsx
Server-hydrated public tenant contextlumie-frontend/src/entities/tenant/providers/PublicTenantContextHydrator.tsx
URL-backed list statelumie-frontend/src/entities/student/model/search-params.ts, lumie-frontend/src/shared/lib/useUrlPageParam.ts

Server State With TanStack Query

QueryProvider mounts QueryClientProvider once at the root. getQueryClient() uses:

  • a fresh query client per server request
  • a stable singleton query client in the browser

Default query behavior is defined centrally in src/shared/lib/query-client.ts:

  • server staleTime is Infinity to avoid duplicate fetches during one SSR pass
  • browser staleTime is five minutes
  • query cache garbage collection stays warm for ten minutes
  • window-focus refetch is disabled by default
  • long-running polling is opt-in and should stop on terminal state or timeout instead of staying as an unbounded background refetch

Most API reads and writes come from Orval-generated hooks that delegate to src/shared/api/orval-mutator.ts, which in turn uses the shared apiRequest() and apiUpload() helpers. Handwritten hooks still exist where the generated client is not enough, but they use the same shared fetch primitives.

Session And Auth State

Authenticated user state is not kept in a custom global store.

  • Server layouts call getServerUser() for access control.
  • Client components use useMe() or useMeQuery().
  • apiRequest() handles 401 retry with tryRefreshToken(), which returns a typed success/failure result instead of a bare boolean.
  • AUTH_017 from the backend means the current browser session was replaced by another login in the same device category. The shared API layer maps that code to the URL-safe reason=session_replaced login notice.
  • sessionAccessor and sessionCache let shared API code read tenant slug, tenant redirect context, and clear session state without importing entity code into shared.
  • sessionCache.getTenantRedirectContext() prefers current-tenant query data, then backend-verified tenant URL fields from cached /v1/me, then browser host/path fallback for cold auth-boundary redirects.

This keeps auth concerns close to the query layer instead of duplicating them in multiple stores.

Shared Client UI State With Zustand

Zustand is currently used for two narrow client bridges.

src/shared/providers/AuthModalProvider.tsx keeps:

  • whether the modal is open for login or registration
  • the sanitized callbackUrl
  • actions for open, reset, and URL-driven initialization

src/entities/tenant/providers/PublicTenantContextHydrator.tsx keeps the server-resolved public tenant for the current /:customId landing page. usePublicTenantAuthContext() reads that store before it falls back to browser public-tenant queries, so the shared auth modal can use the same verified tenantSlug that rendered the landing page.

Both stores are intentionally narrow. Most page-level UI state still stays local to the component that owns it.

Form State With React Hook Form

Forms are built with React Hook Form and Zod-based validation through zodResolver.

Current patterns in the codebase include:

  • single-screen forms such as login or profile updates
  • modal or drawer forms for CRUD flows
  • multi-step forms with FormProvider, such as onboarding
  • useWatch() or useFormContext() for derived field behavior inside composed form sections

Validation schemas usually live in entity or feature model layers so the form UI stays thin.

URL State

List-heavy screens treat the URL as part of the state model.

  • students use useStudentListSearchParams() and parseStudentListParams()
  • Q&A uses parseQnaListParams() and search-param update helpers
  • page-only lists use useUrlPageParam()

The important convention is that URL parsing and React Query key generation share the same source helpers. That reduces refetch mismatches between route transitions and list views.

What Is Not Used

  • No Redux store
  • No MobX
  • No general-purpose React Context state layer for mutable product state

React Context is reserved for provider-style concerns, while mutable app data is handled by Query, forms, Zustand, or local component state.

Verification

cd lumie-frontend
rg -n "staleTime|QueryClientProvider|tryRefreshToken|sessionAccessor|create\\(|useStudentListSearchParams|useUrlPageParam" \
src
npm run lint

Success means the grep finds query defaults, refresh/session state, Zustand store creation, and URL-state helpers, and npm run lint completes without ESLint errors. For the session replacement flow, npm run test:unit -- base AuthModalProvider sessionCache tenantRedirect should pass the focused unit tests around auth failure reasons, tenant-aware login URLs, and cold-cache tenant redirect context.