React conventions — apps/web
Durable conventions for the apps/web React app (React 19.2, React Router 8, TanStack Query 5, Base UI, Panda CSS, Vite 8). These are the patterns established while moving the health round-trip to its documented shape — the first and, at the time of writing, only feature — so a second feature follows them rather than re-deciding.
The build model (Vite, Panda's PostCSS pipeline, the Orval contract pipeline) lives in stack.md. This file is about code shape — how a feature is laid out, where state lives, and how server data reaches a component.
Feature-folder layout
apps/web/src is laid out as:
api/— the contract boundary. OwnsApiProvider(the one placeQueryClientis constructed),QueryBoundary(composes TanStack'sQueryErrorResetBoundarywith the sharedErrorBoundary), thesuspenseOptionsadapter, and one facade hook per endpoint (useHealth.ts). Re-exports its public surface throughindex.ts. Its own tests live inapi/__tests__/.src/apidoes not move undershared/— it is the Orval codegen target named bystack.md, and relocating it would change generated artefacts for a cosmetic gain.features/<feature>/— leaf-only. A feature folder owns its components, hooks, route table (routes.tsx), styles (styles.ts), constants and tests (<feature>/__tests__/).shared/— shared-only:ui/for presentational components used by two or more features (ErrorBoundary),lib/for pure, React-free helpers.App.tsx— the one layout route: the sticky header (brand plus nav),QueryBoundary,Suspense, and an<Outlet />that renders whichever feature route matched. Loading and error UI are declared here, once, never inside a component that needs the data. Its styles live insrc/styles.ts.main.tsx— assembles the feature route tables it is given into one router and mountsApiProvideraround it. It declares no route of its own — see The manifest rule below.
A feature never imports another feature, by any path. On a second consumer, the shared thing is promoted to shared/ui (presentational), shared/lib (pure, no React import) or src/api (server data) — never reached across a features/x/../y path.
Naming
A filename equals the symbol it exports: components are PascalCase (HealthPage.tsx), hooks and plain modules camelCase (usePlanner.ts, suspenseOptions.ts). The exception is a module named for its contents rather than a single export — routes.tsx, index.ts, styles.ts — which is the only case where a file may export several symbols.
A component rendered directly by a route carries the Page suffix (HealthPage); a component it renders internally does not.
This is Biome-enforced, not a guard test: useFilenamingConvention (not on by default; raised to error) with filenameCases: ["export", "camelCase"], scoped to apps/web/** through overrides — unscoped it would fail every kebab-case file in apps/api. "export" means "equal to the name of one export in the file," which is what admits both PascalCase components and camelCase hooks/modules under one setting. src/test-setup.ts failed this rule and was renamed to testSetup.ts.
biome.json carries a second, more specific override for apps/web/**/__tests__/**, adding "PascalCase" to admit test files named after the PascalCase component they cover (HealthPage.test.tsx), and it repeats "export" and "camelCase" rather than listing "PascalCase" alone: a more specific Biome overrides entry replaces filenameCases outright, it does not merge with the less specific one it sits inside. Verified empirically — narrowing the __tests__ override to filenameCases: ["PascalCase"] alone and rerunning biome check on apps/web/src immediately failed every camelCase-named test file (boundary.test.ts, suspenseOptions.test.ts, …), which a merge would have continued to admit through the outer rule. A future override that only lists the addition it wants will silently drop everything the outer rule was granting.
biome.json carries a third override, for apps/web/**/*.stories.tsx, also adding "PascalCase" for the same reason the __tests__ override does: useFilenamingConvention reads the filename segment before the first dot, so Button.stories.tsx is checked as Button and needs the same PascalCase admission. A colocated story file sits beside its component, not inside __tests__/, so it needs its own override entry rather than reusing that one (spec 022 research V3).
The apps/web/** override's includes also excludes apps/web/src/api/generated/** ("!apps/web/src/api/generated/**"), and the exclusion is load-bearing, not cosmetic: Orval emits files like endpoints/recipe-graph/recipe-graph.ts in kebab-case, which is neither camelCase nor equal to any export the file makes — removing the exclusion fails useFilenamingConvention on every such file (verified the same way, by temporarily widening includes and relinting apps/web/src/api/generated). Renaming those files to satisfy the rule is not an option: they are Orval output, regenerated by pnpm verify:contract, which fails CI on any drift between the committed generated files and a fresh orval run — a hand-renamed file is drift.
The manifest rule
main.tsx assembles feature route tables and contains no route definition of its own — the role app.module.ts plays for the api. App.tsx is one exception: it is the layout route that wraps every feature's routes in the shell (sticky header, Suspense, error boundary), declared in main.tsx rather than in any features/*/routes.tsx table, so the naming rule above ("every routes.tsx element ends in Page") stays a statement about feature routes only.
The other exception lives inside a feature's own routes.tsx: a nested-route layout wrapper — a component that renders no page of its own, only a tab bar or similar chrome plus an <Outlet/> for whichever child route matched — carries the Layout suffix instead of Page (LegendariesLayout, wrapping the /legendaries catalog and /legendaries/ranking tabs). The guard test (conventions.test.ts, P1 #4) accepts either suffix on a route's element:.
Routes
Web routes: /legendaries → LegendariesLayout, a tab shell rendering <Outlet/> over its children — /legendaries (index) → LegendariesPage and /legendaries/ranking → RankingPage — plus /legendaries/:id → LegendaryDetailPage as a sibling outside the tab layout, and /health → HealthPage (moved off /). / is deliberately unrouted — a dashboard is a later spec — so it falls through to react-router's built-in DefaultErrorComponent: an unstyled "Unexpected Application Error!" heading plus a console error. Accepted deliberately for now, not an oversight (spec 012 research V4).
Navigation uses NavLink, which sets aria-current="page" on the active link itself — verified in react-router@8.3.0's source, which destructures "aria-current": ariaCurrentProp = "page" and forwards it to the rendered Link. Never hand-compute it from useLocation.
Data flow
Server data reaches a component exactly one way: a facade hook in src/api, called by the component, returning validated data directly — never a status field to branch on, never undefined to guard.
- Suspense-only. A facade hook wraps
useSuspenseQuery, neveruseQuery. No component contains a loading branch, an error branch, or a fetch call for server data — the fallback comes from the route's<Suspense>boundary (declared once, inApp.tsx), and a failed request or a response that fails Zod validation surface the same way: as a throw, caught byQueryBoundary. - The facade.
@tanstack/react-queryis imported only withinsrc/api;src/api/generatedis imported only withinsrc/api. Everywhere else calls a named hook (useHealth). Orval does not wire its generated Zod validators into its generated hooks, so the facade is where response integrity is actually enforced —useHealthparsesquery.data.datathrough the generated schema before returning it, so a malformed payload throws from the validator rather than reaching a component. - Why
suspenseOptionsexists. Orval types the generatedqueryFnasQueryFunction | typeof skipToken;useSuspenseQueryforbidsskipToken, and this repo'sexactOptionalPropertyTypes: trueremoves the usual slack — the generated options factory does not typecheck as-is against the suspense hook.src/api/suspenseOptions.tsis a six-line, tested, named adapter that narrows this once, so every facade hook composes it rather than each improvising its own cast. The narrowing has to live in a plain function, never inline before theuseSuspenseQuerycall — anifguard there would itself be a conditional-hook violation and would fail the compiler's build check (below).
The three state homes
Non-server state has three homes and no others:
- URL search params — filters and what the user is looking at.
- TanStack Query, through
src/api— everything the server knows, including the player's owned materials (they arrive from the GW2 API throughapps/api, so they are server state, not a client-side inventory store). useState— ephemeral UI only (open, hover, focus).
No global store, no context used as a data store.
Styling
panda.config.ts defines the token layer design-system.md describes. Components reference tokens by name; a literal hex, rgb()/rgba() or hsl()/hsla() value in apps/web/src is rejected.
Styles live in styles.ts, never in the component file. A component file contains markup and behaviour; every css() / cva() call sits in a styles.ts beside it and is imported by name (import { cardStyles } from './styles') — including one-off inline ones, which is where the bloat starts. styles.ts is one of the modules named for its contents rather than a single export, like routes.tsx. Scope: one per folder that needs styling — src/styles.ts for the shell, one per feature folder. A feature with several styled components keeps them in that one file, grouped by component; split it only when the file itself becomes the problem. <Name>.styles.ts is not the convention — Biome's useFilenamingConvention reads the segment before the first dot, so LegendariesPage.styles.ts is checked as LegendariesPage, which matches no export in a style module and is not camelCase. Adopting it would mean a new Biome override; styles.ts needs none.
A style variant starts as a colocated cva in the feature's styles.ts; it becomes a config recipe only when the component is promoted to shared/ui.
Enforced by Biome: nothing outside a styles.ts may import styled-system — the import a css()/cva() call always needs, hoisted, inline or dynamic.
Memoization
The React Compiler is enabled for apps/web. useMemo, useCallback and React.memo are not written by hand.
How it's wired. @vitejs/plugin-react 6 exposes no babel option — Vite 8 runs on Rolldown/Oxc, and passing an unsupported babel key is ignored in silence rather than erroring. The compiler is wired as a separate Rolldown Babel plugin instead, in apps/web/vite.config.ts:
import babel from '@rolldown/plugin-babel';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
plugins: [
react(),
babel({ presets: [reactCompilerPreset({ panicThreshold: 'all_errors' })] }),
],This adds four devDependencies: babel-plugin-react-compiler, @rolldown/plugin-babel, @babel/core, and @types/babel__core — and reintroduces Babel into a Vite 8 build that is otherwise Babel-free. That trade was accepted deliberately: measured on the three components that exist today, it costs roughly +110 ms on the Vite build (259–265 ms baseline → 366–370 ms wired, warm, pnpm build in apps/web). This is a per-file transform, so the cost grows with file count and this number does not generalise to a planner-sized app.
The bail-out detector. panicThreshold: 'all_errors' turns a component the compiler cannot safely transform into a build failure naming the file and the rule, rather than a silent skip. No linter carries this: Biome 2.5.5's full rule set (523 rules) has no React Compiler rule — searching it for compiler, Compiler or memoiz returns zero React hits. The compiler is its own detector.
Compiler gotcha: no default value on a destructured parameter. babel-plugin-react-compiler 1.0.0 raises a BuildHIR::lowerAssignment Todo ("expected left to be an LVal, got AssignmentPattern") on a component parameter destructured with a default — function Button({ type = 'button', ...props }) — which panicThreshold: 'all_errors' turns into the same build failure the row above describes. Default in the function body instead (type ?? 'button'), never in the destructure. This only surfaces once the component is reachable from a route: the compiler compiles the module graph (Two limits, below), so a component that exists on disk but renders from nowhere stays invisible to it — exactly what happened here. Spec 019's Button (type = 'button') and Tooltip (side = 'top') both compiled clean through every earlier task and only tripped this the moment T5 mounted them at /ui, the first route to render either; both were fixed by moving the default into the body (CLAUDE.md's bug-fix exception — no spec change, a failing regression build and the fix).
The "use no memo" escape. A component the compiler genuinely cannot compile may opt out with the "use no memo" directive. The convention is that it carries a comment on the same line or immediately above it saying why — but that comment requirement is enforced by code review only, not by any guard test or lint rule: grep -rn "use no memo" apps/web/src today returns nothing, and nothing in this repo would fail if it did. This matters more than the other unenforced conventions below, because "use no memo" is also the one escape hatch that disarms panicThreshold: 'all_errors' for the file that carries it — a directive dropped with no comment, or no reason behind it, silently trades away the build-failure guarantee the row above describes, and nothing here would catch that trade happening.
Testing
- Vitest, jsdom. Feature component tests render against a mocked facade (
vi.spyOn(api, 'useHealth')) — fast, no network, no loading/error branch to simulate because the component has none. - The facade's own tests use MSW so the generated hook, the Zod validator and the malformed-payload path are exercised for real, once, in the layer that owns them (
api/__tests__/useHealth.test.tsx). MSW needs an explicitmsw: falseentry inpnpm-workspace.yaml'sallowBuilds— its postinstall only copies a browser service worker the jsdom tier never uses; without the entrypnpmblocks on an unresolved placeholder (the same gatestack.mddocuments for@swc/core). - Tests live in
<feature>/__tests__/andsrc/api/__tests__/, not colocated — see Where this diverges. - A
*.stories.tsxfile is typechecked (tsconfig.json'sincludecoverssrc/**/*.tsx) but never run as a test — Vitest'sincludeis*.test.ts(x)only, so a story is a compile-time check plus a manual Storybook render, not a third test tier (spec 022 research V4). - Every acceptance scenario / success criterion in the feature spec maps to a named test (project Definition of Done).
Enforcement
Every convention above that a machine can check names exactly one owner. No convention is checked by two layers, and none of the rows below is aspirational — each was run against this repo before being written down here. That one-owner claim was briefly untrue and is now repaired: the react-query / src/api/generated rule was checked by three layers at once (a scanner in conventions.test.ts, a second in api/__tests__/boundary.test.ts, and Biome), so both scanners were deleted rather than left as decoration that could rot out of agreement with the linter.
| Convention | Owner | Fails |
|---|---|---|
Filename equals exported symbol (useFilenamingConvention, apps/web/**) | Biome | pnpm lint |
| No cross-feature import | guard test (__tests__/conventions.test.ts) | pnpm test |
@tanstack/react-query / src/api/generated imported only inside src/api | Biome (noRestrictedImports) | pnpm lint |
styled-system imported only inside a styles.ts | Biome (noRestrictedImports) | pnpm lint |
No hand-written useMemo/useCallback/memo | guard test | pnpm test |
No literal colour (#hex, rgb()/rgba(), hsl()/hsla()) | guard test | pnpm test |
Every route element is named <Name>Page | guard test | pnpm test |
A compiler bail-out is detectable, not silent (panicThreshold: 'all_errors') | React Compiler | pnpm build only |
How the two import rules are scoped in biome.json. noRestrictedImports options do not merge — a more specific overrides entry replaces them outright (the same trap the naming rule documents above), so the two bans cannot be one entry per rule. They are three entries, later ones winning: apps/web/src/** bans both groups; apps/web/src/**/styles.ts re-lists only the react-query group (so a style module may import styled-system, and nothing else it was granted); apps/web/src/api/** re-lists only the styled-system group (so the facade and Orval's output may import react-query). Adding a fourth entry that lists only its own addition would silently drop the rest.
Verified empirically before this was written down, by linting throwaway files under src/features/tmpcheck/: a component importing styled-system, a module importing @tanstack/react-query, one importing src/api/generated, one doing it from a feature-localapi/ folder, and one using dynamic import() — all five error; a styles.ts importing styled-system passes but the same file importing react-query still errors; and useFilenamingConvention keeps firing on a bad filename, confirming the new entries did not displace the older ones.
What the guards do not catch. The guard suite stays as it is — these are its current edges, known and accepted rather than hidden, closable when a feature actually needs them:
- Dynamic
import()is invisible to the guard tests. They match onlyfrom '...', solazy(() => import('../health/HealthPage'))is a cross-feature import that passes — worth knowing because route-level code splitting is the expected next step for this app. The two Biome import rules do not share this edge:noRestrictedImportsflagsimport()too (verified above). shared/→features/andapi/→features/are unchecked. The cross-feature guard only walks files underfeatures/, so a file inshared/orsrc/apiimporting a feature never runs through it.- The literal-colour guard matches
#hex,rgb(),hsl()only. Named CSS colours (color: 'red'), modern colour functions (oklch(),lab(),color-mix()), and.cssfiles are not scanned —src/index.cssis never read. - The styles rule watches the
styled-systemimport, not styling itself. A hardcoded atomic class string (className="fs_3xl") or an inlinestyleprop in a component needs no import and passes — both are already forbidden by other means (the literal-colour guard, tokens-never-literals) or simply not worth a second scanner yet. - The route-naming guard reads
element: <Name>only. React Router'sComponent:andlazy:route properties bypass thePage-suffix rule. - A feature folder without a
routes.tsxmakes the guard suite ERROR rather than fail cleanly, because it reads that file unconditionally. A sharp edge worth remembering: createroutes.tsxwhen you create a feature folder. - The guards assert that the violation list is empty, not that it names the offending file. SC3's "names the offending file" is emergent from Vitest's array diff on failure, not an asserted behaviour.
Two limits, stated plainly rather than left to be discovered:
- The compiler runs only in the build, and only sees files in the module graph.
vitest.config.tswiresreact()without the Babel plugin, so a bail-out failspnpm buildand neverpnpm test— CI must run the build, not the suite alone, for the bail-out row above to hold. A rule violation in a file that exists on disk but is not imported by anything stays invisible until something imports it; it was confirmed to build clean while unreferenced. - A failed request reaches the boundary only when there is no data to show. TanStack Query's own guidance is that errors from
useSuspenseQuerythrow to a boundary "if there is no other data to show" — a background refetch that fails over a warm cache keeps rendering the stale value instead of throwing. The facade's "throw on failure" behaviour therefore holds for the first load and does not hold for a background refetch; treat continued rendering of stale data after a known-bad refetch as this documented behaviour, not a bug.
Five conventions with no enforcement layer. These are prose only. No guard test and no linter checks them today; violating one will not fail any command. They become enforceable when the planner gives them something to check:
- Algorithmic code lives in plain
.tsmodules with no React import, so it is tested as a function rather than through a render. - The three state homes (URL search params / TanStack Query /
useState) and no others — nothing currently scans for a hand-rolled store or a context used as a data cache. - A colocated
cvabecomes a config recipe only when promoted toshared/ui— nothing currently checks that a recipe used by two features was actually promoted rather than copied. - A
"use no memo"directive carries a comment saying why — nothing currently checks for the directive's presence, let alone whether a comment sits beside it. See The"use no memo"escape above for why this is the one gap in the list worth reading in full rather than skimming past. @base-ui/reactis imported only insideshared/ui(spec 019 R2). A consumer imports the finishedButton/Input/Dialog/Tooltipfromshared/ui; nothing imports a Base UI part or astyled-systemrecipe directly to use one. This is the closed-component boundarydesign-system.mdnames (Closed components) — chosen as a documented convention rather than a guard test in the same session that wrote it (spec 019 plan, Alternatives considered); nothing currently scans for a stray@base-ui/reactimport outsideshared/ui.
Where this diverges
An agent trained on generic React advice, or on this repo's own apps/api conventions, will reach for patterns this app has deliberately not adopted. Do not "correct" toward them:
- PascalCase filenames, against the api's kebab-case (
nestjs.md).apps/web's Biome override is scoped precisely so the two apps can disagree. __tests__/folders, against the api's colocated<name>.test.tsbeside the file it covers.- No hand-written memoization — the compiler owns it;
useMemo/useCallback/React.memoare a guard-test failure, not a judgement call. - No global state library — three state homes only (above), no Redux/Zustand/Jotai-shaped store.
- Suspense-only data access — no
useQuerywith astatusbranch anywhere inapps/web; every facade hook isuseSuspenseQuerythroughsuspenseOptions.