React: Rules & Project Structure (2026)

History

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)

  1. 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.")
  2. 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. Long return with
    nested ternaries → break it up. (≈ the 5-line-method extract reflex.)
  3. 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
    typed variant. Three+ related props → pass one object. (≈ "no more than 4 params.")
  4. 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.
  5. Composition over conditionals. Replace flag-driven if ladders and variant
    booleans with composition or a small variant → component map; replace render
    sprawl with small named components. (≈ polymorphism over case.)
  6. 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.
  7. 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 manual useMemo/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
    via packages/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 staleTime so you're not refetching constantly; default 0 is often too eager
    for dashboards.
  • Mutate then invalidate: useMutationqueryClient.invalidateQueries (or
    setQueryData for optimistic updates with useOptimistic). 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).
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 a variant.
  • A hook doing two unrelated jobs? → split into named hooks.
  • Flag-driven if ladder 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