React: Rules & Project Structure (2026)
React: Rules & Project Structure (2026)
When to use it: writing or reviewing React components, hooks, or a new React
app — or any time a component is growing large, prop lists are ballooning, or you're
deciding how to structure a feature, fetch data, or pick packages. The "Sandi Metz
for React": guard-rails that keep components small, focused, and replaceable, plus an
opinionated 2026 stack. Targets large, data-heavy authenticated apps (React 19 era).
The first half is component design rules — the same instincts as Sandi Metz' Rules,
translated to React. The second half is the 2026 stack & structure. Rules are
defaults, not dogma — see "Breaking a rule."
Part 1 — Component guard-rails (Sandi Metz, translated)
- One responsibility per component. A component that fetches, transforms, and
renders is three things. Keep components mostly presentational; push data and
effects into hooks. (≈ "a class does one thing.") - Keep components short. When a component grows past roughly a screenful, or a JSX
block "knows too much," extract a sub-component or a custom hook. Longreturnwith
nested ternaries → break it up. (≈ the 5-line-method extract reflex.) - Limit props; watch the booleans. Many props — especially several boolean flags —
signal a missing component or a job for composition (children/slots) or a single
typedvariant. Three+ related props → pass one object. (≈ "no more than 4 params.") - One job per hook. Extract data access (
useQuery), side effects, and derived state
into named custom hooks (useLogs,useDebouncedValue) so the component stays
declarative. A hook that does fetching and unrelated UI state is two hooks. - Composition over conditionals. Replace flag-driven
ifladders and variant
booleans with composition or a small variant → component map; replace render
sprawl with small named components. (≈ polymorphism overcase.) - Colocate by feature. A component lives next to its hook, its API calls, and its
types. If a block "knows too much" about another feature, that's a boundary smell. - Server state is not client state. Never copy fetched data into
useState/Zustand.
The query cache is the state. (React's cardinal rule — see Part 4.)
Breaking a rule
Break a rule only if you have a good reason — and can state it in one sentence in the PR.
"This component is long because splitting the form hides the field order" is a reason.
"I didn't feel like extracting" is not.
Part 2 — Stack choice (2026)
- React 19 baseline: Server Components, Actions,
use(),useOptimistic; no more
forwardRef; the React Compiler reduces manualuseMemo/useCallback(verify it's on). - Authenticated dashboard (most screens behind login, highly interactive) →
Vite SPA + TanStack Router + TanStack Query. Type-safe routing, no RSC complexity. - Need SSR/streaming in the same ecosystem → TanStack Start.
- Next.js (App Router) only when you also need a content/SEO marketing site tightly
integrated with RSC. Common split: marketing (Next/Astro) + dashboard SPA (Vite). - Don't adopt RSC reflexively for an auth-gated dashboard — small payoff, real complexity.
Part 3 — Project structure
Organize by feature/domain, not by file type. No top-level components/ hooks/ utils/
mega-folders.
apps/web/src/
├─ routes/ # TanStack Router file-based routes (thin — delegate to features)
├─ features/
│ ├─ logs/ # the domain owns everything it needs
│ │ ├─ log-explorer.tsx
│ │ ├─ use-logs.ts # queries/mutations (TanStack Query)
│ │ ├─ log-table.tsx
│ │ └─ api.ts
│ ├─ uptime/
│ └─ incidents/
├─ components/ # ONLY truly cross-feature shared UI
├─ lib/ # cn(), fetch client, helpers
├─ hooks/ # cross-feature hooks
└─ index.css # Tailwind v4 @theme tokens
Rule: a feature is self-contained; cross-feature sharing is promoted up deliberately,
not by default. For multiple apps, use a pnpm workspaces + Turborepo monorepo with
packages/ui (design system), packages/api-client (generated), packages/types
(shared Zod schemas), packages/config.
Part 4 — Data & state
- Server state → TanStack Query (
useQuery/useMutation). Caching, background
refetch, polling. This is most of your "state." - Client state → minimal. Reach for Zustand only for genuine global UI state
(command palette, sidebar, prefs). Never mirror server data into a client store. - Forms → React Hook Form + Zod (
zodResolver); share Zod schemas with the backend
viapackages/types. - API contract: polyglot backend (e.g. Go) → OpenAPI codegen (
openapi-typescript
/ orval) or Connect (buf). TS-only backend → tRPC.
useQuery patterns that matter
- Stable, structured query keys:
['logs', { projectId, filter }]— keys are the
cache identity and the invalidation target. - Tune
staleTimeso you're not refetching constantly; default 0 is often too eager
for dashboards. - Mutate then invalidate:
useMutation→queryClient.invalidateQueries(or
setQueryDatafor optimistic updates withuseOptimistic). Don't hand-sync local copies. - Realtime without render storms: SSE/WebSocket → buffer + throttle (flush every
~250 ms) → write into the Query cache; virtualized rendering means only visible rows
re-render. Never push a high-frequency stream straight into React state. - Lists: TanStack Table (headless) + TanStack Virtual; virtualize anything that
can exceed a few hundred rows (log streams, monitor lists, live tail).
Part 5 — Recommended packages
| Concern | Pick |
|---|---|
| Routing | TanStack Router |
| Server state | TanStack Query |
| Client state | Zustand (sparingly) |
| Forms / validation | React Hook Form + Zod |
| UI | shadcn/ui + Radix + Tailwind v4 |
| Charts | uPlot (hot, high-density) + Recharts/Tremor (cards) |
| Tables | TanStack Table + TanStack Virtual |
| Icons | lucide-react |
| Animation | motion (formerly Framer Motion) |
| Command palette | cmdk |
| Toasts | sonner (shadcn's toast is deprecated) |
| Dates | date-fns (or Temporal polyfill) |
| i18n | i18next / react-i18next |
| Lint/format | Biome (fast, single tool) or ESLint + Prettier |
| Unit/component test | Vitest + Testing Library |
| E2E | Playwright |
| Build | Vite |
UI note: shadcn/ui is production-ready on Tailwind v4 + React 19 — you copy in
components you own (Radix + Tailwind), not an opaque dep. Tailwind v4 is CSS-first
(@theme in CSS, OKLCH colors, @import "tailwindcss"). Avoid the generic-AI look:
customize tokens, brand font, radii/shadows/density — shadcn defaults are a starting
point, not the final look.
Part 6 — Performance
- Code-split per route (TanStack Router lazy routes).
- Virtualize all long lists.
- Buffer high-frequency updates into the cache (see Part 4), don't render every tick.
- Memoize deliberately; with the React Compiler enabled, much manual memoization is
unnecessary — verify before hand-optimizing.
Review checklist
- Any component doing fetch + transform + render? → split; push data into a hook.
- Any component much longer than a screenful, or deep nested ternaries? → extract.
- Many props / multiple boolean flags? → composition (
children/slots) or avariant. - A hook doing two unrelated jobs? → split into named hooks.
- Flag-driven
ifladder choosing UI? → variant map / composition. - Server data copied into
useState/Zustand? → use the Query cache. - Long list not virtualized? → TanStack Virtual.
- High-frequency stream written straight to React state? → buffer + throttle into cache.
- Folder-by-type at the top level? → folder-by-feature.
- Any rule broken without a one-line justification? → fix or justify.
Anti-patterns (reject in review)
- Folder-by-type (
components/ hooks/ services/at top) for a large app. - Server data duplicated into Zustand/Redux instead of the Query cache.
- Reaching for Next.js RSC on a pure auth-gated dashboard "because it's modern."
- Un-virtualized lists of thousands of rows.
- High-frequency websocket updates in component state → render storms.
- shadcn defaults verbatim → looks like every other AI app.
See also
- Sandi Metz' Rules — the same instincts for Ruby/Rails objects.
- Rails Production Playbook — the backend counterpart (TanStack Query pairs with an
OpenAPI-typed Rails/Go API; share Zod/JSON contracts). - Sources: shadcn/ui — Tailwind v4,
shadcn/ui — React 19, TanStack
(Router / Query / Table / Virtual) official docs.