# 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 Router / server components / SSR (that is `nextjs`); NOT native screens (that is `react-native`).

- Skill: `ericrisco/react` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add ericrisco/react`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ericrisco/react/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: ericrisco (https://skillmd.com/u/ericrisco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ericrisco/react

---


# 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)                        |

```tsx
// 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.

```tsx
// 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.

```tsx
// 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.

```tsx
// 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:

```tsx
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:

```tsx
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/`.

