Start
Foundations
Search for a command to run or a page to navigate to.
Thirty-four rules that apply to every Simpson React app. Each one is a decision you don't have to make again — and each links to the single pattern that shows it.
Rules marked lint are checked automatically — you don't need to remember them. The rest are review conversations.
Chosen once, per layer. Deviating is a governance conversation, not a preference.
Use the approved technology stack. Framework choices are deliberate; switching requires a governance conversation before any new dependency lands.
Don't install a framework or state library that isn't on the approved list. The import graph is lint-gated — unlisted packages fail CI.
Install the design system and its SCSS token layer from the first commit. Retrofitting tokens into an existing component tree is expensive.
| Layer | Use | Version |
|---|---|---|
| Server state | TanStack Query | 5+ |
| Session state | React Context | — |
| Computed / cross-cutting state | Zustand | 4+ |
| Forms with validation | React Hook Form + Zod | 7+ / 3+ |
| Tests | Vitest | — |
| Lint, format, CSS lint | @strongtie/eslint | 1.0+ |
Where a file goes should never be a decision.
Co-locate each component with its hook, test, and story in a single folder. The folder name is the component name; index.ts is the only public export.
Name component folders in PascalCase. Shared utilities live in lib/; shared hooks in hooks/. Nothing else at the root of src/.
Follow the component structure pattern — types at the top, one named hook, one default export component. No logic in the render body.
Keep each component focused on one responsibility. Extract sub-components when JSX exceeds one screen. Prop drilling beyond two levels is a signal to extract context.
Use a query key factory, not ad-hoc strings. Every query and mutation that touches the same resource uses the same key shape.
Four rules, all lint-enforced. Nothing to memorise.
Order imports: Node built-ins → third-party → internal (aliased with @/) → relative. A blank line separates each group.
No default imports from packages that export named members. Use named imports; the linter will tell you if something doesn't match.
Use the @/ alias for all app-internal imports. No ../../ climbing. If you need to climb, the file belongs somewhere else.
Install design-system components from the Simpson registry, not directly from node_modules. Registry installs bring the component source into your repo so you own it.
Pick by where the state lives, not by how it feels.
Use React Context for session-wide values that change rarely — auth, tenant, locale, theme. One context per concern; don't combine unrelated values in one provider.
Don't reach for Zustand when local useState or a query cache is enough. Zustand is for state that genuinely needs to be shared across unrelated parts of the tree.
Keep Zustand stores small and bounded to one domain. Expose computed values as selectors — never derive them in the component. Use devtools middleware in development.
| The state is | Use | Pattern |
|---|---|---|
| A server response | TanStack Query | SD-1 |
| Local to one component | useState | SM-2 |
| Session-wide — auth, tenant, theme | React Context | SM-1 |
| Computed, or shared by unrelated trees | Zustand | SM-3 |
| Persisted across reloads | Zustand + persist | SM-3 |
All of it through TanStack Query, through a custom hook, every time.
Wrap every TanStack Query call in a custom hook. Components never call useQuery or useMutation directly. The hook owns the key, the fetcher, and the options.
Every mutation that touches the cache has a rollback. Write the onError handler before you write the onSuccess handler.
Prefetch on hover for list → detail flows. Don't wait for the user to click; by the time they do, the data should already be in cache.
Use a separate hook with a long staleTime for lookup data — enums, reference lists, org settings. They don't change mid-session; don't refetch them like they do.
A mutation without a rollback is a bug. If the network fails after an optimistic update, the UI must revert — or the user sees data they didn't actually save.
One threshold decides the whole approach.
Use uncontrolled inputs for simple forms — single field, immediate submission, no cross-field validation. Don't pull in React Hook Form for a search box.
Use React Hook Form for multi-step, multi-field, or server-validated forms. Register all fields; use formState.errors for inline feedback.
Validate at the schema level with Zod. One schema per form, shared between client and server. z.infer gives you the TypeScript type for free.
The API returns codes. The UI owns the words.
All API calls go through the shared API client. Never call fetch directly from a component or hook. The client owns auth headers, base URL, and error shape normalisation.
Map status codes to user-facing copy in one place. Components receive a string, never a status code. The mapping file is the source of truth for all error wording.
Every route boundary has an error boundary. Never let a thrown error reach the root. Boundaries at the route level; inline isError handling inside routes.
Show inline errors with a retry action. Don't kill the whole page for a recoverable failure. The error message and the retry button live in the same component.
Four states per async component. Shipping three is a bug.
Handle all four states in order: loading → error → empty → data. If the empty state is missing, users see a blank screen and file a bug.
Match the skeleton to the loaded layout. The skeleton should be structurally identical to the real content — same column count, same card height.
Every empty state has an explanatory message and a primary action. "No results" with no call to action is an incomplete state.
WCAG 2.1 AA, non-negotiable. Most of it is caught by lint and Lighthouse.
Every icon-only button has an accessible name — aria-label or aria-labelledby. The linter catches missing labels on <button> with only SVG children.
Every form field has a visible label and an htmlFor binding. No placeholder-only labels. Placeholders disappear; labels don't.
Color is never the sole means of conveying information. Status indicators pair color with an icon or text; graphs have patterns or labels.
All interactive UI is reachable and operable by keyboard. Use Radix UI primitives — they handle focus trapping, roving tabindex, and keyboard events correctly.
Never remove focus outlines. If the default outline clashes with the design, replace it with focus-visible:ring-2 focus-visible:ring-brand — don't remove it.
Lighthouse accessibility at 100 is a ship gate. Run it in CI and against production before any release. A score below 100 is a blocking issue, not a follow-up.
Tailwind covers the structural and typographic layer. For CSS that Tailwind can't express — print styles, complex selectors, keyframes, @font-face, @layer overrides — add it to apps/docs/styles/globals.css using Tailwind v4's @utility and @layer directives.
Custom utilities registered in globals.css are available as first-class Tailwind classes with full autocomplete and purge support. The pattern is one @utility per concept, named with the craft- or mono- prefix for docs-layer utilities.
@utility craft-prose {
font-size: 0.9375rem;
line-height: 1.7;
max-width: 68ch;
color: var(--color-foreground);
}
/* Print: hide nav, expand content column */
@media print {
.sidebar,
.toc {
display: none;
}
main {
max-width: 100%;
}
}Design standards set the rule; the patterns show the implementation.