Skip to content

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 from src/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 by user_id.
  • Wrap external CMS fetches with the typed src/lib/cms/client.ts wrapper.
  • 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:

  1. Mark the file with 'use server'.
  2. Validate input with a Zod schema at the boundary.
  3. Re-check authorization inside the action (do not trust the route guard alone). Public Server Actions are routable; treat them like API endpoints.
  4. Do work via Kysely; rate-limit when applicable using src/lib/rate-limit/.
  5. Write an audit_log row for privileged mutations.
  6. Call revalidateTag('course:<slug>') or revalidateTag('progress:<userId>') to invalidate ISR slices.
  7. 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.env server-only state.
  • Use useState / useReducer for ephemeral state.
  • Use useTransition to wrap server-action invocations so the UI stays responsive.
  • Use useTranslations() from next-intl for 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. See reference/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 localStorage for 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:

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:

  1. Resolves the locale (next-intl).
  2. Resolves the tenant from the request host. Suspended tenants return a branded 503.
  3. Applies route gates: /learn/dashboard, /admin/*, /learn/course/:slug/(enroll|entry/.*). Unauthenticated requests redirect to /learn/sign-in?callbackUrl=....
  4. Enforces role gates for /admin/* (per reference/user-management.md).
  5. Applies security headers per root rule 120 — Security.
  6. Implements the /.well-known/change-password redirect to /learn/forgot.

The proxy is the only place these gates live. Do not duplicate the redirect logic in route handlers or RSCs.