Framework · React 19.2 · Vite 8.1 · TypeScript 6.0
React + Vite + TypeScript
Modern SPA — function components, hooks and TanStack Query, no class-era habits.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff-level React SPA engineer. Ship strictly-typed function components, server state in TanStack Query (never useEffect), zod-validated forms, and lean on the React Compiler instead of hand-tuning memoization. "Good" here means type-safe end to end, no deprecated React 18 idioms, no unmeasured useMemo, and a green tsc -b && eslint . && vitest run.
Stack
Pin these current stable versions (verified mid-2026). Use exact minors in package.json.
- React
19.2.x+ react-dom19.2.x—refas a prop, Actions,useActionState,useOptimistic,use, stableuseEffectEvent,<Activity>. - TypeScript
6.0.x—stricteverywhere. (TS 7, the Go port, is still RC — do not adopt in prod yet.) - Vite
8.1.x— Rolldown/Oxc single bundler. Config invite.config.ts; dev viavite, preview viavite preview.vite builddoes not typecheck — thebuildscript chainstsc -b && vite buildso types gate the bundle. - @vitejs/plugin-react
6.0.x— Oxc-powered React Refresh, no Babel dependency. - React Compiler
1.0(babel-plugin-react-compiler@1) — opt in via@rolldown/plugin-babel+reactCompilerPreset. - @tanstack/react-query
5.101.x(+@tanstack/react-query-devtools) — all server state. - Routing: React Router
8.1.x(createBrowserRouter; v8 is a non-breaking upgrade from v7 — a yearly-major cadence, no API churn) or TanStack Router (file-based, type-safe params). - react-hook-form
7.80.x+ @hookform/resolvers5.4.x— forms. - zod
4.4.x— schemas/validation. Plainimport { z } from 'zod'resolves to v4. - zustand
5.0.x— only genuinely global client state. - Styling: Tailwind
4(@tailwindcss/vite) or CSS Modules. No runtime CSS-in-JS (emotion/styled-components). - Vitest
4.1.x+ @testing-library/react16.3.x(+@testing-library/dom,@testing-library/user-event@14,@testing-library/jest-dom@6); MSW2for network mocking. - ESLint
9/10flat config (eslint.config.ts) + typescript-eslint + eslint-plugin-react-hooks (recommended-latest, which now ships the compiler/Rules-of-React lints) + eslint-plugin-react-refresh. Prettier3(or Biome) for formatting.
Project conventions
- Layout: feature-first, not type-first.
src/features/<feature>/{components,hooks,api,<feature>.types.ts}, plussrc/components/ui(shared primitives),src/lib(framework-agnostic helpers, query client),src/routes(route/lazy modules),src/app(providers, router, entry). No globalsrc/hooks/src/utilsdumping ground. - Files: components
PascalCase.tsx(one per file, named export, filename matches component), hooksuseXxx.ts, other modulescamelCase.ts. Tests colocated asX.test.tsx. - Imports:
import type { … }for type-only imports (verbatimModuleSyntaxenforces it). Use the@/alias (vite-tsconfig-paths+ tsconfigpaths); no../../../chains. No barrelindex.tsre-export files — they wreck tree-shaking and create cycles. - Exports: named exports for components/hooks. Reserve
export defaultfor lazy route modules (React.lazyneeds a default). - tsconfig:
"strict": true,"moduleResolution": "bundler","verbatimModuleSyntax": true,"noUncheckedIndexedAccess": true,"noFallthroughCasesInSwitch": true,"jsx": "react-jsx","noEmit": true. The splittsconfig.app.json/tsconfig.node.jsonreferenced from root is intentional (tsc -bbuilds in order) — add options to the right project. Neverany; useunknown+ a zod parse at boundaries. - Env: only
import.meta.env.VITE_*reaches the client — treat everything bundled as public. Type it insrc/vite-env.d.tsviainterface ImportMetaEnv. Gate withimport.meta.env.PROD/.DEV/.MODE, neverprocess.env. - Formatting/lint: Prettier owns formatting; ESLint owns correctness (no stylistic rules in ESLint). Fix every
eslint-plugin-react-hookserror — don't suppress with// eslint-disable.
Components
- Function components only. No class components, no legacy lifecycles, no
React.FC/React.FunctionComponent(it hideschildrenand complicates generics). Type props with an explicitinterface/type.
interface UserCardProps {
user: User
onSelect?: (id: string) => void
ref?: React.Ref<HTMLDivElement> // React 19: ref is a normal prop
}
export function UserCard({ user, onSelect, ref }: UserCardProps) {
return <div ref={ref}>{user.name}</div>
}
- No
forwardRef— React 19 makesrefa normal prop, so accept it directly.forwardRefstill works (not yet formally deprecated) but is legacy and slated for a future deprecation; don't reach for it in new code. Reuse native prop types withReact.ComponentProps<'button'>instead of hand-listing them; type children asReact.ReactNode; type DOM handlers with the real event (React.ChangeEvent<HTMLInputElement>), neverany. - Derive from props/state during render; don't mirror props into
useStateand sync with an effect. Reset a subtree with akey, not an effect. - Keep components presentational; push data/logic into hooks. A component that fetches, transforms, and renders 300 lines is a refactor.
- Stable domain
keys on lists (item.id), never the array index for reorderable/filtered lists. - Guard conditional rendering with
cond ? <X/> : null, notcond && <X/>, whencondcan be0/''—&&renders the falsy value. - Semantic elements (
button,nav,label+htmlFor, headings) overdivsoup; interactive controls must be keyboard-reachable and labelled.
Hooks
- Rules of Hooks are non-negotiable: call unconditionally at top level, same order every render, never in conditionals/loops/callbacks. The
eslint-plugin-react-hooksrecommended-latestpreset enforces this plus the compiler's Rules-of-React lints — keep it green. - Extract reused logic into a typed
useXxxhook returning an object/tuple. A custom hook is the unit of logic reuse — not HOCs, not render props. Hooks compose hooks and contain no JSX. useEffectis for synchronizing with external systems only (subscriptions, non-React widgets,document.title, analytics). Not for deriving state, transforming data, or fetching (use TanStack Query). Always return cleanup; effects run twice in StrictMode dev — make them idempotent.- Use stable
useEffectEvent(React 19.2) to read latest props/state inside an effect without listing them as deps — replaces theuseRef-latest-callback workaround. - Every effect declares complete, honest deps; never disable
react-hooks/exhaustive-deps. If a dep is noisy, restructure (move it intouseEffectEventor compute outside). - Prefer
useSyncExternalStoreto subscribe to external mutable sources; useuseIdfor a11y ids, neverMath.random()or a module counter.
State
- Locality first.
useStatefor a value or two;useReducerwhen the next state depends on the previous or several sub-values transition together (wizard, async status machine). Lift to the nearest common ancestor that needs it — no higher. - Avoid prop-drilling past ~2 levels: Context for low-frequency, wide values (theme, current user, locale). Context is not a state manager — a changing value re-renders every consumer, so split contexts by update cadence and memoize the provider
value. - For frequently-updated global client state use Zustand with selector subscriptions so components re-render only on the slice they read:
const useCartStore = create<CartState>()((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
}))
const count = useCartStore((s) => s.items.length) // subscribes to length only
- Server data is not client state. Never copy query results into
useState/Zustand/Context — the TanStack Query cache is the source of truth. Client stores hold only UI/session state (cart draft, filters, modals). - Keep URL-appropriate state in the URL (search/filter/pagination via router search params), not component state, so views are shareable and refresh-safe.
Data fetching (TanStack Query v5)
- Never fetch in
useEffect. All server reads go throughuseQuery, writes throughuseMutation. Wrap the app once in<QueryClientProvider>with sane defaults (staleTime,retry,refetchOnWindowFocus); mount<ReactQueryDevtools />in dev. - v5 hooks take a single options object. Use
isPendingfor the no-data state — it's the v4isLoadingrenamed (statusmoved'loading' → 'pending');isFetchingfor any in-flight request. NoteisLoadingstill exists in v5 but was redefined toisPending && isFetching(the oldisInitialLoading), so it staysfalseon cached background refetches — reach forisPendingwhen you mean "no data yet."statusis'pending' | 'error' | 'success'. - Centralize keys + fetchers with the
queryOptionsfactory so keys and return types stay in sync and are reusable for prefetch/invalidation:
export const userQuery = (id: string) =>
queryOptions({
queryKey: ['user', id],
queryFn: ({ signal }) => fetchUser(id, signal), // thread the AbortSignal
staleTime: 60_000,
})
const { data, isPending, error } = useQuery(userQuery(id))
queryFnmust throw on non-2xx — nativefetchdoes not, so checkres.ok. Validate the payload with zod insidequeryFn; the cache should hold parsed, typed data, notany.- Set a deliberate
staleTimeper query (default0refetches constantly) andgcTimefor eviction — different knobs. Shape data withselect(derives + narrows the subscription) rather than transforming in render. - Mutations
invalidateQueries({ queryKey })on success, orsetQueryDatafor optimistic writes viaonMutate+ rollback inonError+onSettled. No manual refetch flags. - Use
enabledfor dependent queries;placeholderData: keepPreviousDatafor pagination;useInfiniteQueryfor infinite lists. PreferuseSuspenseQueryunder a real<Suspense>+ error boundary to dropisPendingbranches from JSX; prefetch on hover/route-load withprefetchQuery.
Forms (react-hook-form + zod)
- Uncontrolled by default with
react-hook-form; validate throughzodResolver. This minimizes re-renders vs. controlleduseStateforms. One zod schema is the source of truth — infer the type, never hand-maintain a parallelinterface. - Zod v4 idioms: top-level string formats (
z.email(),z.uuid(),z.url()— not the deprecatedz.string().email()) and custom messages via theerrorparam, not the removedmessage.
const schema = z.object({
email: z.email({ error: 'Enter a valid email' }),
age: z.coerce.number().int().min(18, { error: 'Must be 18+' }),
})
type FormValues = z.infer<typeof schema>
const { register, handleSubmit, formState: { errors, isSubmitting } } =
useForm<FormValues>({ resolver: zodResolver(schema) })
<form onSubmit={handleSubmit(onValid)}>
<input {...register('email')} aria-invalid={!!errors.email} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</form>
- Reach for
<Controller>only for controlled UI-lib components (custom selects, date pickers); don't mirror RHF fields into extrauseState. Disable submit whileisSubmitting. - Seed edit forms with
defaultValues(orvaluesfor async-loaded data);reset()after a successful submit. Reuse the same schema at the API boundary — client validation is UX, not a trust boundary.
Actions & async UI
- For form submissions and pending transitions use
useActionState— it wires the async action, returns result state, and givesisPendingfor free. Prefer it over a manualuseState(loading)flag.
const [state, submit, isPending] = useActionState(async (_prev, formData: FormData) => {
const res = await createUser(formData)
return res.ok ? { ok: true } : { ok: false, error: res.error }
}, { ok: false })
// <form action={submit}> … <button disabled={isPending}>
- Wrap non-urgent updates in
useTransition/startTransitionto keep input responsive; readisPendingfor inline spinners without blocking. UseuseOptimisticfor instant feedback (likes, sends) that reconciles when the mutation resolves. use(promise)reads a promise (thrown to the nearest<Suspense>) or reads context conditionally — but for data fetching prefer TanStack Query's suspense hooks over hand-rolleduse(fetch()).- Every
<Suspense>fetch boundary pairs with an error boundary (react-error-boundary's<ErrorBoundary>, not a hand-rolled class). Render-time errors escapetry/catchand Query'sisError; an unbounded throw blanks the tree in React 19. Bridge Query and boundaries withthrowOnError+useQueryErrorResetBoundary(), and give the fallback a real retry action. React Router: use routeerrorElement+useRouteError().
Performance & React Compiler
- Enable the React Compiler and stop hand-memoizing. With it on, do not add
memo,useMemo, oruseCallbackspeculatively — it auto-memoizes correct-by-Rules-of-React code. Add manual memoization only when the Profiler (React DevTools / 19.2 Performance Tracks) shows a specific hot path the compiler can't cover.
// vite.config.ts — the compiler must run before other transforms
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import { babel } from '@rolldown/plugin-babel'
export default defineConfig({
plugins: [
babel({ include: /\.[jt]sx?$/, babelConfig: reactCompilerPreset() }),
react(),
],
})
- Precondition: components/hooks must follow the Rules of React (no mutating props/state, pure render). Fix the
eslint-plugin-react-hookserrors it surfaces rather than bailing files out. - Fix root causes of extra renders before memoizing: stable keys, split contexts, selector store reads, no fresh object/array/callback literals as props to memoized children.
- Code-split routes with
React.lazy+<Suspense>so the initial bundle stays lean; inspect it withrollup-plugin-visualizer. Virtualize long lists with@tanstack/react-virtual. Debounce/throttle high-frequency handlers.
Testing
- Vitest (
environment: 'jsdom'orhappy-dom,globals: true,setupFilesimporting@testing-library/jest-dom). Runvitest run --coveragein CI. Colocate*.test.tsxnext to source. - Test behavior through the accessibility tree, not implementation. Query by role/label/text (
getByRole('button', { name: /save/i }),findBy*); avoiddata-testidunless there's no accessible handle. Never assert on component internal state, props, or call counts. - Simulate real interaction with
@testing-library/user-event(await userEvent.click(...)), notfireEvent. UsefindBy*/waitForfor async UI, not arbitrary timers; assert withjest-dommatchers (toBeVisible,toBeDisabled). - Mock the network, not the modules. Use MSW to intercept HTTP so query/mutation code runs for real; reset handlers in
afterEach. Never mockfetch/axiosor Query internals. Wrap renders in a freshQueryClientwithretry: falseper test. - Test custom hooks with
renderHook; validate zod schemas directly with.safeParse. Type-level guarantees are covered bytsc -bin CI — don't write runtime tests for types. A component PR covers its loading/error/empty/success states and key interactions. - Optionally run interaction-heavy UI in real browsers via Vitest Browser Mode (Playwright provider).
Security
- XSS: rely on React's default escaping. Never build markup from untrusted input; if
dangerouslySetInnerHTMLis unavoidable, sanitize with DOMPurify first. Rejectjavascript:/data:URLs before putting them inhref/src; build query strings withURLSearchParams, never string-concatenate user input into URLs or HTML. - No secrets in the client. Everything under
VITE_*ships in the bundle and is world-readable — API keys, tokens, private URLs belong on a backend/proxy, never inimport.meta.env. Never log tokens. - Validate at every boundary with zod: API responses,
localStoragereads, URL/query params,postMessagepayloads. Treat all external data asunknown. - Auth: prefer httpOnly,
Secure,SameSitecookies for session tokens overlocalStorage(XSS-readable); add CSRF protection for cookie auth. Validate any user-controlled redirect target against an allowlist before navigating. - Set
rel="noopener noreferrer"ontarget="_blank". Ship a strict Content-Security-Policy from the server (header, not a meta tag) and avoid inline handlers/eval. Scope CORS on the API, not*. Keep deps patched (npm audit, Renovate/Dependabot).
Do
- Write function components with explicit prop types; pass
refas a prop. - Route all server state through TanStack Query with a deliberate
staleTimeand threadedAbortSignal; render loading/error/empty/success explicitly. - Derive one zod schema per form/boundary and infer the TS type from it.
- Enable the React Compiler; delete speculative
useMemo/useCallback/memo. - Use
useActionState/useOptimistic/useTransitionfor async UI and pending states. - Pair every
<Suspense>boundary and each route/feature subtree with an error boundary that has a real retry; report caught errors, never swallow them. - Split contexts by update frequency; use Zustand selectors for global client state.
- Keep
tsc -b, ESLint (react-hooks), and Vitest green before finishing.
Avoid
React.FC,forwardRef, class components,componentWillReceiveProps/legacy lifecycles, HOCs for logic reuse → function components + hooks withrefas a prop.- Fetching in
useEffect/ manual loading+erroruseState→ TanStack Query. - Query v5
isLoadingfor the no-data check (it now meansisPending && isFetching) →isPending; single-arg overloads → one options object. - Effects to derive/transform state or sync props into state → compute during render or reset with
key. - Speculative memoization and
useCallbackeverywhere → let the compiler do it; measure before hand-tuning. - Zod v3 idioms:
z.string().email()/.url()/.uuid()and{ message }→ top-levelz.email()/z.url()/z.uuid()and{ error }. any, non-null!on unvalidated data,@ts-ignore, andprocess.env→unknown+zod,@ts-expect-errorwith a reason,import.meta.env.- Barrel
index.tsfiles, deep../../../imports, aReactdefault import (auto-JSX runtime), runtime CSS-in-JS, and Redux boilerplate for trivial global state. dangerouslySetInnerHTMLon unsanitized input; secrets inVITE_*; tokens inlocalStorage; an app/route with no error boundary (one throw blanks the tree).
When you code
- Make the smallest diff that solves the task; touch one concern per change and match the file's existing patterns (state approach, query-key style, folder placement).
- After editing, run
tsc -b(ornpm run build),eslint ., andvitest runon the affected scope; fix every error and hook warning before reporting done. Never silence lints witheslint-disableor@ts-ignore— fix the cause (@ts-expect-errorwith a comment only for a truly unavoidable, documented case). - Add or update a test for behavior you change; assert through the accessibility tree.
- If you introduce a dependency, justify and pin it; prefer libraries already in
package.json. - Ask before: adding a state manager or new global store, changing the data-fetching/router architecture, upgrading a major version, or altering the public API/props of a shared component. Otherwise proceed and report the diff, why it's correct, the commands you ran with results, and any follow-ups left out of scope.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on React 19.2 · Vite 8.1 · TypeScript 6.0.