Promptheus/rules53 rule sets · CC0Promptheus hub ↗

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.

reactvitetypescriptspatanstack-query

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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-dom 19.2.xref as a prop, Actions, useActionState, useOptimistic, use, stable useEffectEvent, <Activity>.
  • TypeScript 6.0.xstrict everywhere. (TS 7, the Go port, is still RC — do not adopt in prod yet.)
  • Vite 8.1.x — Rolldown/Oxc single bundler. Config in vite.config.ts; dev via vite, preview via vite preview. vite build does not typecheck — the build script chains tsc -b && vite build so 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/resolvers 5.4.x — forms.
  • zod 4.4.x — schemas/validation. Plain import { 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/react 16.3.x (+ @testing-library/dom, @testing-library/user-event@14, @testing-library/jest-dom@6); MSW 2 for network mocking.
  • ESLint 9/10 flat 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. Prettier 3 (or Biome) for formatting.

Project conventions

  • Layout: feature-first, not type-first. src/features/<feature>/{components,hooks,api,<feature>.types.ts}, plus src/components/ui (shared primitives), src/lib (framework-agnostic helpers, query client), src/routes (route/lazy modules), src/app (providers, router, entry). No global src/hooks / src/utils dumping ground.
  • Files: components PascalCase.tsx (one per file, named export, filename matches component), hooks useXxx.ts, other modules camelCase.ts. Tests colocated as X.test.tsx.
  • Imports: import type { … } for type-only imports (verbatimModuleSyntax enforces it). Use the @/ alias (vite-tsconfig-paths + tsconfig paths); no ../../../ chains. No barrel index.ts re-export files — they wreck tree-shaking and create cycles.
  • Exports: named exports for components/hooks. Reserve export default for lazy route modules (React.lazy needs a default).
  • tsconfig: "strict": true, "moduleResolution": "bundler", "verbatimModuleSyntax": true, "noUncheckedIndexedAccess": true, "noFallthroughCasesInSwitch": true, "jsx": "react-jsx", "noEmit": true. The split tsconfig.app.json/tsconfig.node.json referenced from root is intentional (tsc -b builds in order) — add options to the right project. Never any; use unknown + a zod parse at boundaries.
  • Env: only import.meta.env.VITE_* reaches the client — treat everything bundled as public. Type it in src/vite-env.d.ts via interface ImportMetaEnv. Gate with import.meta.env.PROD/.DEV/.MODE, never process.env.
  • Formatting/lint: Prettier owns formatting; ESLint owns correctness (no stylistic rules in ESLint). Fix every eslint-plugin-react-hooks error — don't suppress with // eslint-disable.

Components

  • Function components only. No class components, no legacy lifecycles, no React.FC/React.FunctionComponent (it hides children and complicates generics). Type props with an explicit interface/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 makes ref a normal prop, so accept it directly. forwardRef still 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 with React.ComponentProps<'button'> instead of hand-listing them; type children as React.ReactNode; type DOM handlers with the real event (React.ChangeEvent<HTMLInputElement>), never any.
  • Derive from props/state during render; don't mirror props into useState and sync with an effect. Reset a subtree with a key, 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, not cond && <X/>, when cond can be 0/''&& renders the falsy value.
  • Semantic elements (button, nav, label+htmlFor, headings) over div soup; 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-hooks recommended-latest preset enforces this plus the compiler's Rules-of-React lints — keep it green.
  • Extract reused logic into a typed useXxx hook 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.
  • useEffect is 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 the useRef-latest-callback workaround.
  • Every effect declares complete, honest deps; never disable react-hooks/exhaustive-deps. If a dep is noisy, restructure (move it into useEffectEvent or compute outside).
  • Prefer useSyncExternalStore to subscribe to external mutable sources; use useId for a11y ids, never Math.random() or a module counter.

State

  • Locality first. useState for a value or two; useReducer when 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 through useQuery, writes through useMutation. Wrap the app once in <QueryClientProvider> with sane defaults (staleTime, retry, refetchOnWindowFocus); mount <ReactQueryDevtools /> in dev.
  • v5 hooks take a single options object. Use isPending for the no-data state — it's the v4 isLoading renamed (status moved 'loading' → 'pending'); isFetching for any in-flight request. Note isLoading still exists in v5 but was redefined to isPending && isFetching (the old isInitialLoading), so it stays false on cached background refetches — reach for isPending when you mean "no data yet." status is 'pending' | 'error' | 'success'.
  • Centralize keys + fetchers with the queryOptions factory 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))
  • queryFn must throw on non-2xx — native fetch does not, so check res.ok. Validate the payload with zod inside queryFn; the cache should hold parsed, typed data, not any.
  • Set a deliberate staleTime per query (default 0 refetches constantly) and gcTime for eviction — different knobs. Shape data with select (derives + narrows the subscription) rather than transforming in render.
  • Mutations invalidateQueries({ queryKey }) on success, or setQueryData for optimistic writes via onMutate + rollback in onError + onSettled. No manual refetch flags.
  • Use enabled for dependent queries; placeholderData: keepPreviousData for pagination; useInfiniteQuery for infinite lists. Prefer useSuspenseQuery under a real <Suspense> + error boundary to drop isPending branches from JSX; prefetch on hover/route-load with prefetchQuery.

Forms (react-hook-form + zod)

  • Uncontrolled by default with react-hook-form; validate through zodResolver. This minimizes re-renders vs. controlled useState forms. One zod schema is the source of truth — infer the type, never hand-maintain a parallel interface.
  • Zod v4 idioms: top-level string formats (z.email(), z.uuid(), z.url() — not the deprecated z.string().email()) and custom messages via the error param, not the removed message.
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 extra useState. Disable submit while isSubmitting.
  • Seed edit forms with defaultValues (or values for 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 gives isPending for free. Prefer it over a manual useState(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/startTransition to keep input responsive; read isPending for inline spinners without blocking. Use useOptimistic for 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-rolled use(fetch()).
  • Every <Suspense> fetch boundary pairs with an error boundary (react-error-boundary's <ErrorBoundary>, not a hand-rolled class). Render-time errors escape try/catch and Query's isError; an unbounded throw blanks the tree in React 19. Bridge Query and boundaries with throwOnError + useQueryErrorResetBoundary(), and give the fallback a real retry action. React Router: use route errorElement + useRouteError().

Performance & React Compiler

  • Enable the React Compiler and stop hand-memoizing. With it on, do not add memo, useMemo, or useCallback speculatively — 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-hooks errors 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 with rollup-plugin-visualizer. Virtualize long lists with @tanstack/react-virtual. Debounce/throttle high-frequency handlers.

Testing

  • Vitest (environment: 'jsdom' or happy-dom, globals: true, setupFiles importing @testing-library/jest-dom). Run vitest run --coverage in CI. Colocate *.test.tsx next to source.
  • Test behavior through the accessibility tree, not implementation. Query by role/label/text (getByRole('button', { name: /save/i }), findBy*); avoid data-testid unless 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(...)), not fireEvent. Use findBy*/waitFor for async UI, not arbitrary timers; assert with jest-dom matchers (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 mock fetch/axios or Query internals. Wrap renders in a fresh QueryClient with retry: false per test.
  • Test custom hooks with renderHook; validate zod schemas directly with .safeParse. Type-level guarantees are covered by tsc -b in 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 dangerouslySetInnerHTML is unavoidable, sanitize with DOMPurify first. Reject javascript:/data: URLs before putting them in href/src; build query strings with URLSearchParams, 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 in import.meta.env. Never log tokens.
  • Validate at every boundary with zod: API responses, localStorage reads, URL/query params, postMessage payloads. Treat all external data as unknown.
  • Auth: prefer httpOnly, Secure, SameSite cookies for session tokens over localStorage (XSS-readable); add CSRF protection for cookie auth. Validate any user-controlled redirect target against an allowlist before navigating.
  • Set rel="noopener noreferrer" on target="_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 ref as a prop.
  • Route all server state through TanStack Query with a deliberate staleTime and threaded AbortSignal; 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/useTransition for 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 with ref as a prop.
  • Fetching in useEffect / manual loading+error useState → TanStack Query.
  • Query v5 isLoading for the no-data check (it now means isPending && 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 useCallback everywhere → let the compiler do it; measure before hand-tuning.
  • Zod v3 idioms: z.string().email()/.url()/.uuid() and { message } → top-level z.email()/z.url()/z.uuid() and { error }.
  • any, non-null ! on unvalidated data, @ts-ignore, and process.envunknown+zod, @ts-expect-error with a reason, import.meta.env.
  • Barrel index.ts files, deep ../../../ imports, a React default import (auto-JSX runtime), runtime CSS-in-JS, and Redux boilerplate for trivial global state.
  • dangerouslySetInnerHTML on unsanitized input; secrets in VITE_*; tokens in localStorage; 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 (or npm run build), eslint ., and vitest run on the affected scope; fix every error and hook warning before reporting done. Never silence lints with eslint-disable or @ts-ignore — fix the cause (@ts-expect-error with 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.

Back to top ↑