State Management And Hooks¶
Audience: Developers wiring data into pages, components, and admin flows.
Scope: Where state lives, how to read and mutate it, and the shared hooks/utilities that exist today. Behaviour is enforced by code in
src/.
Where State Lives¶
The LMS is a Next.js 16 App Router app. Server-first is the default.
| Layer | When to use |
|---|---|
| Server components | Default for any page. Read directly from Kysely / CMS client; pass scalars to client components. |
| Server actions | All mutations. Validate input with Zod, re-check authorization inside the action, write audit rows. |
| URL query params | Filters, search, pagination, sort. Source of truth for shareable list-page state. |
useState / useReducer |
Ephemeral UI state in client components (open/closed flags, controlled inputs, drag handles). |
| Zustand | Cross-component ephemeral UI state in a client tree (course sidebar visibility, table sort). |
| Database | Anything that must persist across requests (enrollments, progress, attempts, audit, sessions). |
| JWT claims | Tenant/role identity for fast edge checks. Re-validated against the DB for privileged actions. |
There is no Redux, MobX, Recoil, React Query, or SWR in the LMS. Do not introduce another global store without a documented exception.
Server Components¶
Default to async server components for any route. Patterns:
- Resolve the session with
await auth()(re-exported fromsrc/auth/index.ts). - Use the guards in
src/auth/guards.ts(requireSession,requireRole,requireCourseAccess) for protected routes. - Run Kysely queries directly. Scope every multi-tenant query by
tenant_id; every per-user query byuser_id. - Wrap external CMS fetches with the typed
src/lib/cms/client.tswrapper. - Use
next: { revalidate: 60, tags: ['course:<slug>', ...] }so admins can invalidate slices without redeploying.
Server Actions¶
Server actions are co-located with the route segment that owns them, in actions.ts or _actions.ts. The action contract:
- Mark the file with
'use server'. - Validate input with a Zod schema at the boundary.
- Re-check authorization inside the action (do not trust the route guard alone). Public Server Actions are routable; treat them like API endpoints.
- Do work via Kysely; rate-limit when applicable using
src/lib/rate-limit/. - Write an
audit_logrow for privileged mutations. - Call
revalidateTag('course:<slug>')orrevalidateTag('progress:<userId>')to invalidate ISR slices. - Return user-safe data; never bubble raw error messages.
Cross-route helpers used by actions live in src/lib/<feature>/. Client components do not import those helpers directly — they call the server action.
Client Components¶
'use client' modules:
- Must NOT import Auth.js, Kysely, the email transport, or any module that touches
process.envserver-only state. - Use
useState/useReducerfor ephemeral state. - Use
useTransitionto wrap server-action invocations so the UI stays responsive. - Use
useTranslations()fromnext-intlfor any user-facing copy.
Zustand (Restricted Use)¶
Zustand is allowed only for ephemeral cross-component UI state in a single client tree. Two examples shipped today:
- Course sidebar — non-persisted store backing the
[keyboard shortcut and the toggle button. Seereference/toggleable-navigation.md. - Admin tables — table sort, filter expansion, and other transient table state.
Rules:
- Never use Zustand for layout/visibility flags that must persist across server-rendered navigations — those belong in URL params or the database.
- Never persist Zustand state to
localStoragefor layout/visibility decisions; SSR will not see it and you will get hydration mismatches. - Document each store in the owning feature's reference page.
URL-Driven Filters & Pagination¶
List/admin pages put q, page, pageSize, role, and status filters in URL query params. Server components read them, run server-side queries, and render results. Patterns are documented in root rule 055 — Data Listing Patterns and root rule 058 — Search Input UX Policy.
Helper utilities live in src/lib/search-params.ts (with co-located tests).
Shared Hooks¶
Custom hooks live in src/hooks/. Today's set is intentionally small:
useFocusTrap— focus trapping/restoration for dialogs, sheets, overlays.- Any hook with an effect must follow the React rules in root skill — Vercel React Best Practices.
Add new hooks to src/hooks/ only when at least two consumers use them. Otherwise keep the hook co-located with the component.
Proxy Layer (proxy.ts)¶
src/proxy.ts runs before every request. It:
- Resolves the locale (
next-intl). - Resolves the tenant from the request host. Suspended tenants return a branded 503.
- Applies route gates:
/learn/dashboard,/admin/*,/learn/course/:slug/(enroll|entry/.*). Unauthenticated requests redirect to/learn/sign-in?callbackUrl=.... - Enforces role gates for
/admin/*(perreference/user-management.md). - Applies security headers per root rule 120 — Security.
- Implements the
/.well-known/change-passwordredirect to/learn/forgot.
The proxy is the only place these gates live. Do not duplicate the redirect logic in route handlers or RSCs.