Skip to content

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. Owns ApiProvider (the one place QueryClient is constructed), QueryBoundary (composes TanStack's QueryErrorResetBoundary with the shared ErrorBoundary), the suspenseOptions adapter, and one facade hook per endpoint (useHealth.ts). Re-exports its public surface through index.ts. Its own tests live in api/__tests__/. src/api does not move under shared/ — it is the Orval codegen target named by stack.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 in src/styles.ts.
  • main.tsx — assembles the feature route tables it is given into one router and mounts ApiProvider around 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, never useQuery. 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, in App.tsx), and a failed request or a response that fails Zod validation surface the same way: as a throw, caught by QueryBoundary.
  • The facade. @tanstack/react-query is imported only within src/api; src/api/generated is imported only within src/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 — useHealth parses query.data.data through the generated schema before returning it, so a malformed payload throws from the validator rather than reaching a component.
  • Why suspenseOptions exists. Orval types the generated queryFn as QueryFunction | typeof skipToken; useSuspenseQuery forbids skipToken, and this repo's exactOptionalPropertyTypes: true removes the usual slack — the generated options factory does not typecheck as-is against the suspense hook. src/api/suspenseOptions.ts is 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 the useSuspenseQuery call — an if guard 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 through apps/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:

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 explicit msw: false entry in pnpm-workspace.yaml's allowBuilds — its postinstall only copies a browser service worker the jsdom tier never uses; without the entry pnpm blocks on an unresolved placeholder (the same gate stack.md documents for @swc/core).
  • Tests live in <feature>/__tests__/ and src/api/__tests__/, not colocated — see Where this diverges.
  • A *.stories.tsx file is typechecked (tsconfig.json's include covers src/**/*.tsx) but never run as a test — Vitest's include is *.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.

ConventionOwnerFails
Filename equals exported symbol (useFilenamingConvention, apps/web/**)Biomepnpm lint
No cross-feature importguard test (__tests__/conventions.test.ts)pnpm test
@tanstack/react-query / src/api/generated imported only inside src/apiBiome (noRestrictedImports)pnpm lint
styled-system imported only inside a styles.tsBiome (noRestrictedImports)pnpm lint
No hand-written useMemo/useCallback/memoguard testpnpm test
No literal colour (#hex, rgb()/rgba(), hsl()/hsla())guard testpnpm test
Every route element is named <Name>Pageguard testpnpm test
A compiler bail-out is detectable, not silent (panicThreshold: 'all_errors')React Compilerpnpm 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 only from '...', so lazy(() => 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: noRestrictedImports flags import() too (verified above).
  • shared/ → features/ and api/ → features/ are unchecked. The cross-feature guard only walks files under features/, so a file in shared/ or src/api importing 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 .css files are not scanned — src/index.css is never read.
  • The styles rule watches the styled-system import, not styling itself. A hardcoded atomic class string (className="fs_3xl") or an inline style prop 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's Component: and lazy: route properties bypass the Page-suffix rule.
  • A feature folder without a routes.tsx makes the guard suite ERROR rather than fail cleanly, because it reads that file unconditionally. A sharp edge worth remembering: create routes.tsx when 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:

  1. The compiler runs only in the build, and only sees files in the module graph. vitest.config.ts wires react() without the Babel plugin, so a bail-out fails pnpm build and never pnpm 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.
  2. A failed request reaches the boundary only when there is no data to show. TanStack Query's own guidance is that errors from useSuspenseQuery throw 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 .ts modules 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 cva becomes a config recipe only when promoted to shared/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/react is imported only inside shared/ui (spec 019 R2). A consumer imports the finished Button/Input/Dialog/Tooltip from shared/ui; nothing imports a Base UI part or a styled-system recipe directly to use one. This is the closed-component boundary design-system.md names (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/react import outside shared/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.ts beside the file it covers.
  • No hand-written memoization — the compiler owns it; useMemo/useCallback/React.memo are 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 useQuery with a status branch anywhere in apps/web; every facade hook is useSuspenseQuery through suspenseOptions.