React Architecture
Targets React 19 with TypeScript strict. Component library defaults to Radix primitives + Tailwind (covered deeply in [ui-ux-architect](../../frontend/ui-ux-architect/SKILL.md)). When this skill applies to Next.js apps, server-side concerns live in [nextjs-architect](../nextjs-architect/SKILL.md). See [STACK.md](STACK.md) for pinned dependencies.
1. TypeScript posture — strict, always
tsconfig.json baseline in [RECIPES.md § tsconfig.json — strict baseline](RECIPES.md#tsconfigjson--strict-baseline).
- No
any. Useunknownfor truly untyped boundaries, then narrow. - No type assertions (
as) except at validated I/O boundaries (after a Zod parse, etc.). noUncheckedIndexedAccessis on —arr[0]isT | undefined, you handle the undefined. Catches a whole class of runtime crashes.exactOptionalPropertyTypesdistinguishes{x?: string}from{x: string | undefined}. Pick the right one per use case.- Discriminated unions over enum + cast.
type Status = {kind: "loading"} | {kind: "ok"; data: T} | {kind: "err"; msg: string}.
2. Project structure — feature-based
Mirrors [fastapi-architect](../fastapi-architect/SKILL.md#1-project-structure--feature-based) and [gin-architect](../gin-architect/SKILL.md#1-project-structure--feature-based) — same shape, different language. One folder per bounded feature. Full tree in [RECIPES § Feature-based project structure](RECIPES.md#feature-based-project-structure).
- A feature folder owns its UI, hooks, types, schemas, and API queries. Cross-feature reuse moves to
src/components/. schemas.tsper feature — Zod schemas for every request body and response shape. Parse at the API boundary; throw on parse failure.index.tsexports the public surface of each feature. Importing fromfeatures/users/components/UserListis cheating; import fromfeatures/usersand re-export deliberately.
3. Components
Function components only
- No class components. Hooks cover every legitimate case.
- One component per file. File name matches the component name (
UserList.tsxexportsUserList). - Default export sparingly. Named exports compose better with refactors and IDE tooling.
Naming + structure
Skeleton: [RECIPES.md § Component skeleton — Props type + loading/error guard](RECIPES.md#component-skeleton--props-type--loadingerror-guard).
Propstype colocated, named<Component>Props.- Loading / error / empty states are real components, not inline ternaries with cryptic JSX. Per [ui-ux-architect](../../frontend/ui-ux-architect/SKILL.md).
- **
aria-*attributes** when the role isn't implicit. Per [ui-ux-architect](../../frontend/ui-ux-architect/SKILL.md). - Event handlers passed as props, not constructed inside the component (which would change identity every render and break
memo).
Composition over configuration
Reach for the composition shape first; a 20-prop <DataTable> is a smell. Comparison: [RECIPES.md § Compound vs. configuration component shape](RECIPES.md#compound-vs-configuration-component-shape).
- Compound components export sub-components on the main one (
Tabs.List,Tabs.Tab,Tabs.Panel). childrenis the most underused prop. When in doubt, accept children.- Render props / function-as-child for advanced cases where parent needs the child's state. Use sparingly — usually a custom hook is cleaner.
Memoization, sparingly
- Don't
memoeverything. React 19's compiler handles most cases. Profile before memoizing. useMemo/useCallbackjustified only when:- The memoized value is itself expensive to compute, or
- It's passed to a memoized child and identity matters.
- The wrong reason to add
memo: "to feel safe". Often actively counterproductive (memoize an object that always changes → worse than not memoizing).
4. Hooks
Built-in hooks
Full table of when to use each (useState, useReducer, useContext, useEffect, useLayoutEffect, useId, useTransition, useDeferredValue, use) in [RECIPES § Built-in hooks reference](RECIPES.md#built-in-hooks-reference). Key rule: useEffect is for synchronization with non-React systems, not for fetching — TanStack Query handles fetching.
Custom hooks
- Encapsulate stateful behavior that's reused or that doesn't fit in a component.
useUser(id),useDebounce(value, ms),useEscapeKey(handler). - Name starts with
use— React's lint depends on it. - One responsibility per hook.
useUserAndPostsAndPreferencesis three hooks. - Custom hooks compose other hooks freely. No reason to inline what could be
useUser(id).
Don't fetch in useEffect
- Server state lives in TanStack Query, not
useState+useEffect. The fetch-in-effect pattern is correct for ~zero apps. - The query handles loading, error, retry, dedup, caching, refetch on focus, invalidation — none of which you want to reimplement.
5. State management — three layers
| Layer | What | Tool | |---|---|---| | Server state | Anything fetched from the API — users, orders, etc. | TanStack Query | | Local state | Component-internal — open/closed, form draft, hover | useState / useReducer | | Client global state | Cross-tree, mutable, doesn't fit Context (perf or shape) | zustand when justified |
TanStack Query for server state
- Query keys are arrays, hierarchically structured.
["users", { filter, sort, cursor }]. Drives invalidation patterns. - Define query keys + fetchers per feature in
features/<feature>/api.ts. - Stale time tuned per resource —
staleTime: 1000 * 60is a reasonable default; reference data can go higher. refetchOnWindowFocus: trueas default. Modern app expectation.- Optimistic updates for mutations that the user will see succeed in 99% of cases (likes, votes, simple edits). Roll back on error. The query lib supports this directly with
onMutate+onErrorrollback. - Don't put server data in zustand. Caching, dedup, and freshness already live in TanStack Query; duplicating them is a recipe for inconsistency.
Context for cross-tree static-ish state
- Auth context, theme context, locale context — read by many components, changes rarely.
- Stable provider value — wrap in
useMemoso consumers don't re-render on every parent render. Or split into one provider for the value and one for the dispatcher.
zustand for the rest
- When Context performance is a problem (many consumers re-render on every change), zustand selectors fix it.
- When state has a non-trivial reducer shape with several actions, zustand's set/get is cleaner than
useReducer+ Context. - Don't reach for it preemptively. Most apps go years without needing it.
Skeleton: [RECIPES.md § Zustand store — focused, single-purpose](RECIPES.md#zustand-store--focused-single-purpose).
6. Suspense + ErrorBoundary
React 19's recommendation is: every async boundary has a Suspense + ErrorBoundary above it. Skeleton: [RECIPES.md § Suspense + ErrorBoundary at a route](RECIPES.md#suspense--errorboundary-at-a-route).
<Suspense>for loading,<ErrorBoundary>for errors. The two together replace per-componentif (isPending) ... if (isError) ...ladders.- TanStack Query supports Suspense mode (
useSuspenseQuery) — pairs cleanly with the boundary pattern. - Granularity matters. A single big Suspense for the whole page means one slow query blocks everything. A Suspense per logical region means each fills in as ready.
7. Forms
- Controlled inputs for everything user-typed.
useStateper field for simple forms;react-hook-form(default for non-trivial) for complex forms with cross-field validation. - Zod schemas validate on submit (and optionally on blur for inline feedback). Same schema feeds the API request type.
- Native HTML form semantics —
<form>, `<label