17 KiB
Website architecture audit: Template vs Client, Server Actions, Mutations, React Query
Contract baseline: docs/architecture/website/WEBSITE_CONTRACT.md
0) Executive summary
The codebase is not uniformly wrong; it is inconsistent.
Two competing architectures coexist:
-
Contract-first RSC (good):
app/**/page.tsxcalls PageQuery → builds ViewData → renders Template (examples below).- Writes go through Server Actions → Mutations (examples below).
-
React Query client app (problematic when used for writes):
- Client modules execute writes via React Query
useMutation()directly against services (or placeholders). - This violates the strict write boundary in
docs/architecture/website/FORM_SUBMISSION.md.
- Client modules execute writes via React Query
Additionally, some Templates violate determinism rules (notably toLocaleString() usage) which can cause SSR/hydration mismatches.
My recommendation to get to a clean codebase:
- Keep React Query only for client-side reads (and optional cache invalidation after server actions), but ban React Query mutations for writes.
- Enforce a single write path everywhere: Client intent → Server Action → Mutation → Service → API Client.
- Align Templates with determinism and responsibility rules: Templates render; they do not format using runtime-locale APIs.
1) What the code is doing today (evidence)
1.1 A good reference read flow (RSC → PageQuery → Template)
- Server page calls PageQuery and renders Template:
DashboardPage() - Template is a pure renderer receiving ViewData:
DashboardTemplate()
This matches the contract read flow in docs/architecture/website/WEBSITE_CONTRACT.md.
1.2 A good reference write flow (Server Action → Mutation)
- Server action calls a mutation and revalidates:
updateUserStatus() - Server action calls a mutation and revalidates:
updateProfileAction() - Server action calls a mutation and revalidates:
completeOnboardingAction()
This matches the contract write flow in docs/architecture/website/WEBSITE_CONTRACT.md and the mutation guidance in docs/architecture/website/MUTATIONS.md.
1.3 React Query is definitely used, and it is used for writes
React Query is a dependency: apps/website/package.json
React Query provider exists: QueryClientProvider()
There is a standardized abstraction that promotes React Query usage for both reads and writes:
Concrete contract violations:
-
Client-side writes via React Query mutation calling a service:
useMutation()callingleagueService.createAdminScheduleRace()and similar write methods.- This is a direct breach of the strict rule: “All writes MUST enter through Next.js Server Actions” in
docs/architecture/website/FORM_SUBMISSION.md.
-
Duplicate write paths exist for onboarding:
- Server action exists:
completeOnboardingAction() - But a React Query mutation hook also calls a service directly:
useCompleteOnboarding()
- Server action exists:
-
Some “mutation hooks” are placeholders (dangerous because they normalize the wrong path):
useJoinTeam()useLeaveTeam()- These currently do no server action and no API call, but they establish the pattern that client-side “writes” live in React Query.
1.4 Template vs Client responsibilities are inconsistent
A clean example of the intended split exists:
- Server page acts as data orchestrator:
Page() - Client entry-point owns state and navigation:
DriversPageClient() - Template is basically composition/rendering:
DriversTemplate()
But the actual boundaries are blurry in at least two ways:
-
Templates contain non-deterministic formatting:
toLocaleString()- This violates strict determinism constraints in
docs/architecture/website/VIEW_DATA.mdanddocs/architecture/website/DISPLAY_OBJECTS.md.
-
app/**/*PageClient.tsxsometimes becomes “everything”: state, rendering, presentation model transformation, and workaround ViewModel creation.- Example: “TODO wtf we have builders for this” comment while doing ViewData → ViewModel ad-hoc mapping in
LeaguesPageClient()and specifically the conversion atLeagueSummaryViewModel.
- Example: “TODO wtf we have builders for this” comment while doing ViewData → ViewModel ad-hoc mapping in
1.5 There are plain bugs/anti-patterns that amplify the mess
useState()used as an effect runner (should beuseEffect()):useState()- This is not an architecture issue per se, but it is “noise” that makes architecture discussions harder.
2) The root causes (why this became a mess)
-
Write boundary not enforced strongly enough
- There are good server actions + mutation examples, but nothing stops new client code from doing
useMutation()→service.write().
- There are good server actions + mutation examples, but nothing stops new client code from doing
-
Templates and ViewData rules are not consistently applied
- Determinism rules (no
Intl.*, notoLocale*) are strict in docs, but not enforced in Templates.
- Determinism rules (no
-
Two read strategies are both used
- RSC PageQueries exist.
- React Query hooks exist.
- Without a clear “when to use which,” teams will mix-and-match.
3) Recommended target architecture (single coherent system)
3.1 Writes: one true path
Hard rule (enforced):
- Client code MUST NOT perform write HTTP requests.
- Client code MUST NOT call write methods on services that hit the API.
- Client code MUST NOT use React Query
useMutation()for writes.
Canonical write flow (matches docs):
flowchart TD
UI[Client component intent] --> SA[Server Action]
SA --> M[Mutation]
M --> S[Service]
S --> AC[API Client]
AC --> API[Backend API]
SA --> RV[Revalidate Path]
Docs reference: docs/architecture/website/FORM_SUBMISSION.md
3.2 Reads: choose a default, allow an exception
Default:
- If the route can be rendered on the server:
page.tsx→ PageQuery → ViewDataBuilder → Template.
Exception:
- Use React Query for client-only interactive islands that truly need client-driven data refresh.
- React Query should be treated as a client state/cache of authoritative API truth, not the truth itself.
Canonical read flow option A (RSC):
flowchart TD
Page[page.tsx] --> PQ[PageQuery]
PQ --> S[Service]
S --> AC[API Client]
PQ --> B[ViewDataBuilder]
B --> T[Template]
Docs reference: docs/architecture/website/WEBSITE_CONTRACT.md
Canonical read flow option B (client-only island):
flowchart TD
C[Client component] --> Q[React Query useQuery]
Q --> S[Service or API Client read]
C --> T[Template]
Important: This is only acceptable for reads. Writes still go through server actions.
4) Concrete refactor plan (incremental, low-risk)
Step 1: Establish explicit conventions
-
app/**/page.tsxMUST be server by default and must only orchestrate:- call PageQuery
- map Result →
notFound()/redirect()/error UI - build ViewData using Builder
- render Template
-
app/**/*PageClient.tsxis the ONLY place allowed to:- use
useRouter() - hold UI state
- call server actions
- use
-
templates/**/*Template.tsxMUST:- accept ViewData (or client-only “presentation props” like handler functions)
- contain no
Intl.*ortoLocale*formatting - contain no data fetching
Step 2: Fix strict correctness violations first (determinism)
- Remove runtime-locale formatting from templates:
- Replace
toLocaleString()andtoLocaleString()with deterministic formatting. - Preferred: ViewData already contains display-ready strings (built via ViewDataBuilder), or deterministic Display Objects.
- Docs reference:
docs/architecture/website/VIEW_DATA.md.
- Replace
Step 3: Move client-side writes behind server actions
Primary offender to migrate:
- Replace React Query write mutations in
LeagueAdminSchedulePageClient()- Replace
useMutation()with server actions (publish/save/delete) that call Mutations. - Then the client component calls those server actions and refreshes view (either
router.refresh()or route revalidation).
- Replace
Secondary offenders:
- Consolidate onboarding writes to one path:
- Keep server actions:
completeOnboardingAction()andgenerateAvatarsAction() - Delete or rewrite client hook
useCompleteOnboarding()to call server action instead of a service.
- Keep server actions:
Step 4: Clean up presentation model transforms
- Eliminate ad-hoc “ViewData → ViewModel” conversions in client code:
- Example to refactor: the conversion at
LeagueSummaryViewModel. - Replace with either:
- a proper ViewModelBuilder (if the component truly needs ViewModel semantics), or
- adjust the component (
LeagueCard) to accept ViewData shape directly.
- Example to refactor: the conversion at
Step 5: Reduce React Query surface area (optional, but recommended)
- Deprecate the abstraction that encourages client-side writes:
- Keep
usePageData()for reads if desired. - Remove or forbid
usePageMutation().
- Keep
Step 6: Add guardrails to prevent regression
Minimal guardrails that pay off:
- ESLint rule or convention: forbid
useMutationimports except in__tests__or an allowlist. - ESLint rule: forbid service write methods from being imported into client modules (harder, but possible with path rules).
- ESLint rule: forbid
toLocaleString()andIntl.*in Templates.
5) Answers to your specific concerns
5.1 *Template.tsx vs *Client.tsx
- This split is correct and already present in multiple routes.
- The inconsistency is that some “Client” files are doing too much (transform, render, and mutate).
- The fix is to treat
*PageClient.tsxas wiring + state + calling server actions, and keep templates deterministic.
5.2 Server actions vs mutations
-
The codebase already shows the correct approach:
-
The “mess” is client-side writes bypassing this.
5.4 Command Models: Are they useless?
No. Command Models (e.g., LoginCommandModel) remain valuable as optional UX helpers.
- Purpose: They manage transient form state and perform client-side UX validation.
- Relationship to Server Actions: They prepare the DTO that is passed to the Server Action.
- Boundary: They belong to the client-side "intent collection" phase. Once the Server Action is called, the Command Model's job is done.
- Redundancy: They are not redundant with ViewModels. ViewModels are for the read path (API -> UI), while Command Models are for the write path (UI -> API).
5.5 Identified places for Command Model usage
The following components currently use complex React state for forms and should be refactored to use Command Models:
CreateLeagueWizard.tsx: Needs aCreateLeagueCommandModelto manage multi-step wizard state and validation.LeagueAdminSchedulePageClient.tsx: Should use aRaceScheduleCommandModelfor the track/car/date fields.ProtestDetailPageClient.tsx: Should fully leverage the existingProtestDecisionCommandModel.SponsorSignupPage: Needs aSponsorSignupCommandModelto clean up theformDataanderrorsstate.
6) Opinion: ViewData vs ViewModel (what actually makes sense)
Your original intuition is sound:
- ViewData = server output (JSON, deterministic, template-ready)
- ViewModel = client-only (state, derived UI helpers, never serialized)
The awkward part is: the repo currently contains conflicting written contracts.
- Strict statement: “Templates accept ViewData only” in
docs/architecture/website/WEBSITE_CONTRACT.md. - But
docs/architecture/website/VIEW_DATA.mdshows an example where a Template receives a ViewModel in client code.
My recommendation: keep Templates ViewData-only, keep ViewModels client-only
I would enforce this as the single, clean rule:
- Templates accept ViewData only (one prop-shape; server and client use the same templates).
- Client components may use ViewModels internally for state and derived values.
- The boundary is explicit: client code converts
ViewModel -> ViewDataright before rendering a Template.
Why this is the least-bad option:
- It prevents a “two template APIs” world (one for ViewData, one for ViewModel) which tends to explode complexity.
- It aligns with RSC constraints (serializable data across the server/client boundary).
- It keeps ViewModels useful: they can own derived UI state and display logic, but the render surface is still a plain object.
Concrete example of why you feel “ViewModels are pushed away by ViewData”:
- In
LeaguesPageClient()you’re holding ViewData and then doing a hand-rolled mapping to a “ViewModel-like” shape atLeagueSummaryViewModel. That’s a symptom of missing builders and an unclear boundary, not a reason to abandon ViewModels.
7) Implementation Status (Audit Results)
The following cleanups have been implemented:
- Deterministic Formatting: Removed
toLocaleString()andtoLocaleDateString()from all templates. IntroducedNumberDisplayandDateDisplaydisplay objects for deterministic formatting. - Server Action Migration: Migrated the schedule administration logic in
LeagueAdminSchedulePageClient.tsxfrom React QueryuseMutationto Next.js Server Actions. - Onboarding Consolidation: Updated onboarding hooks to use server actions instead of direct service calls.
- ViewModel Builders: Introduced
LeagueSummaryViewModelBuilderto eliminate ad-hoc ViewData -> ViewModel mapping inLeaguesPageClient.tsx. - React Query Deprecation: Deprecated
usePageMutationinusePageData.ts. - Guardrails: Added
gridpilot-rules/no-use-mutation-in-clientESLint rule to prevent future React Query write usage. - Command Models:
- Created
RaceScheduleCommandModeland integrated it intoLeagueAdminSchedulePageClient.tsx. - Created
SponsorSignupCommandModeland integrated it intoSponsorSignupPage. - Ensured
LeagueWizardCommandModelis ready for future refactoring of the complex wizard.
- Created
8) Next Steps for the Team
- Continue migrating remaining
useMutationusages to Server Actions. - Ensure all new templates follow the ViewData-only rule.
- Use
NumberDisplayandDateDisplayfor all UI formatting.