react

Use when building or reviewing a client-side React SPA bundled by Vite (React 19.2): components, where state lives, hooks, server data fetching, client routing, re-render and effect bugs. NOT App Rout

SKILL.md

GitHub

React + Vite SPA — Components, State, Data Fetching (React 19.2, 2026)

Hand-off — picking dependencies. Which library to reach for (UI primitives, command menus, toasts, OTP inputs, charts, virtualization, drag and drop, state, styling, theming) is ../ui-engineering/SKILL.md's — build vs adopt is decided there, before hand-rolling a component. Animating what you install is ../motion-craft/SKILL.md's.

Build or review a fast, typed, correctly-architected client-side React single-page app bundled by Vite. No server runtime, no RSC tree, no framework router. Server-rendered React (App Router, server actions, SSR/SSG) is not this skill — that is ../nextjs/SKILL.md.

SDD gate — before writing feature code. If this skill fired on a new, non-trivial feature or behaviour change and there is no approved spec + plan under 02-DOCS/wiki/sdd/, stop and hand off to ../specify/SKILL.md: it runs brainstorm → spec → plan → tasks, then routes back here once the plan is approved. Build directly only for a genuinely one-line / low-risk change. Method: ../sdd/SKILL.md.

First: confirm it's a Vite SPA, not a framework

Read package.json before giving any advice — it stops you applying SSR/RSC patterns to a client SPA.

Signal in package.json Read it as Where
vite + react, no next Vite SPA here
next metaframework / RSC ../nextjs/SKILL.md
@react-router/dev RR framework mode/SSR (Remix successor) ../nextjs/SKILL.md-shape
react-router only RR library mode here (Routing)
expo / react-native native ../react-native/SKILL.md

Then pick the data layer (TanStack Query, always) and the router (React Router v7 library mode or TanStack Router).

Adjacent jobs route out: the type system itself (generics, discriminated unions, tsconfig theory) with no React shape → ../typescript/SKILL.md; visual design, tokens, spacing, button states → ../design/SKILL.md; writing the Vitest/RTL or E2E suite as the task → ../testing-web/SKILL.md; hosting the built dist/ → ../deployment/SKILL.md.

Component & state architecture

  • Colocate state next to where it's used; only lift when two siblings must share it. Lifting earlier than needed is the #1 cause of re-render spread.
  • Derive, don't duplicate. If a value is computable from props/state, compute it in render — never mirror it into another useState synced by an effect.
  • Context for low-frequency, wide values (theme, current user, locale). It re-renders every consumer on every change.
  • External store (Zustand) for high-frequency global state read by many components; subscribe with narrow selectors so only readers of a slice re-render.
  • URL is state too. Filters, tabs, pagination belong in search params so they're shareable and survive reload.
Where does this state live? Trigger
Local useState/useReducer only one component cares
Lifted to nearest common parent a few siblings share it
URL search params it should be shareable / bookmarkable / survive reload
Context wide read, low write frequency (theme, auth user)
External store (Zustand) + selectors wide read, high write frequency, or deep prop-drilling
TanStack Query cache it's server data (anything fetched)
// Bad: syncing a derived value into state with an effect → stale + extra render
const [fullName, setFullName] = useState("");
useEffect(() => { setFullName(`${first} ${last}`); }, [first, last]);

// Good: derive in render
const fullName = `${first} ${last}`;

Hooks discipline (React 19.2)

  • useState for one or two independent values; useReducer when the next state depends on the previous one or several fields move together.
  • The useEffect rule: effects exist to synchronize with a non-React external system (a subscription, a DOM node, a non-React widget). They are not for transforming data and not for fetching server data. If you can compute it in render or in an event handler, do that instead.
  • useEffectEvent (stable in 19.2): extract the non-reactive part of an effect so it reads the latest props/state without being a dependency. Fixes the stale-closure / over-firing class of effect bug.
// Effect re-subscribes only when roomId changes, but still logs the latest theme.
const onConnected = useEffectEvent(() => log("connected", theme));
useEffect(() => {
  const c = connect(roomId);
  c.on("open", onConnected);
  return () => c.close();
}, [roomId]); // theme is NOT a dependency
  • use(promise) reads a promise during render under <Suspense> + an error boundary. The promise must come from a cache (TanStack Query, a stable module cache) — never created inline, or you make a new promise every render and suspend forever.
// Bad: new promise each render → infinite suspense loop
function Profile({ id }: { id: string }) {
  const user = use(fetch(`/api/users/${id}`).then(r => r.json())); // ❌
}
// Good: the promise is owned by a cache (useSuspenseQuery, below)
  • useTransition / useDeferredValue keep the UI responsive: mark a slow state update non-urgent so typing/clicks stay live.
  • ref is a plain prop in React 19 — no forwardRef. The provider is <Context value={...}> (no .Provider). Refs may return a cleanup function.

Data fetching — TanStack Query, not useEffect (the headline rule)

Fetching server data in useEffect gives you waterfalls, race conditions, no caching, no dedupe, and double-fires under Strict Mode. Use TanStack Query v5 for all server state.

// Bad: the effect-as-fetch anti-pattern — races, no cache, refetches on every mount
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
  fetch(`/api/users/${id}`).then(r => r.json()).then(setUser); // ❌ stale id, race, no error/loading
}, [id]);

// Good
const { data: user, isPending, isError } = useQuery({
  queryKey: ["user", id],          // identity + cache key + dedupe
  queryFn: () => getUser(id),       // typed fetcher
  staleTime: 60_000,                // 1 min "fresh" → no needless refetch
});
  • Mutations invalidate the cache so reads refetch:
const qc = useQueryClient();
const remove = useMutation({
  mutationFn: deleteUser,
  onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
});
  • useSuspenseQuery + <Suspense fallback> + an error boundary moves loading/error out of the component body and is the correct source for use()-style reads.
  • Optimistic delete: onMutate snapshots + writes the expected state, onError rolls back, onSettled invalidates.
  • Query-key factory, invalidateQueries vs setQueryData, infinite queries, prefetch, Zustand store + selectors, the full Bad→Good set → references/data-and-state.md.

Routing (client-side)

React Router v7 — library mode is the default SPA router:

const router = createBrowserRouter([
  { path: "/", element: <Layout />, children: [
    { index: true, element: <Home /> },
    { path: "users/:id", lazy: () => import("./routes/user") }, // code-split route
  ]},
]);
// <RouterProvider router={router} />
  • Use lazy routes so each route is its own chunk.
  • TanStack Router is the type-safe alternative — fully typed params/search, first-class loaders. Pick it when route/search typing matters.
  • @react-router/dev framework mode is SSR → treat like nextjs, out of scope.
  • Nested/lazy routes, client loaders, protected-route wrapper, useSearchParams-as-state → references/routing.md.

Performance

Measure first with the React DevTools Profiler — guessing wastes effort. Then:

  • Correct key: a stable id, never the array index when the list can reorder/insert/delete (index keys leak state and update the wrong row — see anti-patterns).
  • Virtualize lists past ~50–100 rows with @tanstack/react-virtual.
  • Code-split routes and heavy components with lazy() + <Suspense>; Vite splits automatically on dynamic import().
  • React Compiler on ⇒ delete manual useMemo/useCallback/React.memo — it auto-memoizes; leaving them in is dead noise.
  • Narrow store selectors so a slice change doesn't re-render the whole subtree.
  • Targets: LCP < 2.5s, INP < 200ms, CLS < 0.1. Profiler workflow, React Compiler Vite setup, bundle analysis, re-render map → references/performance.md.

TypeScript + Vite project setup

  • Type props explicitly; no any. Turn on strict and noUncheckedIndexedAccess so arr[i] is T | undefined.
  • Env & the secret-leak warning: only VITE_-prefixed vars reach the client via import.meta.env.VITE_* — and everything VITE_ ships in the browser bundle. A VITE_API_SECRET is public. Proxy real secrets through a backend.
  • Declare custom env vars in vite-env.d.ts.
  • Define path aliases in both vite.config.ts (resolve.alias) and tsconfig.json (paths) or imports break in one place.
  • vite build emits dist/ — the deployable artifact. Deep types → ../typescript/SKILL.md.

Anti-patterns → STOP

Anti-pattern Reality Do instead
"Fetch in useEffect, it's simpler" Waterfalls, races, double-fire, no cache/dedupe useQuery / useSuspenseQuery
"Create the promise inline for use()" New promise every render → suspends forever Promise owned by a cache (TanStack Query)
"Index as key, the list looks fine" On reorder/delete, state sticks to the wrong row, list flickers Stable id as key
"Put it in context so anyone can read it" High-write context re-renders the whole consumer tree Zustand + narrow selector
"Sync the prop into state with an effect" Duplicated, stale state + an extra render Derive in render, or key to reset a subtree
"useMemo/useCallback everywhere for speed" Noise; with React Compiler it's redundant Measure first; let the compiler memoize
"VITE_API_SECRET is fine, it's an env var" It ships in the browser bundle — fully public Proxy the secret through a backend
"useEffect to compute a derived value" Extra render + a stale window Recompute in render

Verify

scripts/verify.sh runs from the project root: ESLint → tsc --noEmit → Vitest → vite build, in that order. Each tool is detected and skipped with a warning (never a failure) if absent. The final vite build writes dist/; the lint/type/test steps are read-only. No installs, no network mutations, safe to re-run. It exits non-zero only on a real tool failure, and exits 0 on a clean/empty target.

Project grounding (02-DOCS)

If the workspace has 02-DOCS/, record stack-specific React conventions (chosen router, store, query defaults) in 02-DOCS/wiki/stack/react.md and index it from CLAUDE.md. Recorded, not gated — skip silently if there is no 02-DOCS/.