State Management
Lumie uses different tools for different state tiers rather than one global store.
State Tiers
| State type | Primary tool | Current usage |
|---|---|---|
| Server state | TanStack Query | Session, lists, detail views, and most CRUD-backed screens |
| Form state | React Hook Form | Login, signup, onboarding, settings, CRUD dialogs, and multi-step forms |
| Shared client UI state | Zustand | Shared auth modal state and server-hydrated public tenant context |
| Route state | Next.js search params | List filters, sorting, pagination, modal entry params |
| Local ephemeral UI state | React component state | Draft input values, dialog state, pending actions, view toggles |
Source Paths
| State boundary | Source path |
|---|---|
| Query defaults and server/browser client split | lumie-frontend/src/shared/lib/query-client.ts |
| Root Query provider | lumie-frontend/src/shared/providers/QueryProvider.tsx |
| Orval fetch bridge | lumie-frontend/src/shared/api/orval-mutator.ts |
| Shared API request and refresh retry | lumie-frontend/src/shared/api/base.ts |
| Server auth state | lumie-frontend/src/entities/session/api/getServerUser.ts |
| Session cache and shared auth accessor bridge | lumie-frontend/src/shared/lib/sessionCache.ts, lumie-frontend/src/shared/api/sessionAccessor.ts |
| Auth redirect reason mapping | lumie-frontend/src/shared/lib/authRedirectReason.ts |
| Client auth modal store | lumie-frontend/src/shared/providers/AuthModalProvider.tsx |
| Server-hydrated public tenant context | lumie-frontend/src/entities/tenant/providers/PublicTenantContextHydrator.tsx |
| URL-backed list state | lumie-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
staleTimeisInfinityto avoid duplicate fetches during one SSR pass - browser
staleTimeis 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()oruseMeQuery(). apiRequest()handles 401 retry withtryRefreshToken(), which returns a typed success/failure result instead of a bare boolean.AUTH_017from 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-safereason=session_replacedlogin notice.sessionAccessorandsessionCachelet shared API code read tenant slug, tenant redirect context, and clear session state without importing entity code intoshared.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()oruseFormContext()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()andparseStudentListParams() - 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.