React + TypeScript (Vite)
Project context
This is a React 19 + TypeScript single-page app built with Vite. No framework — routing, data fetching, and state are explicit. Functional components only, hooks-first, named exports. The codebase favors explicit composition over deep abstraction.
Stack
- React 19
- TypeScript 5+ in strict mode (
"strict": true,"noUncheckedIndexedAccess": true) - Vite 5+ as the build tool
- Tailwind CSS 4 for styling
- React Router v7 (
react-router-dom) or TanStack Router for routing - TanStack Query for server state (when needed)
- Vitest + Testing Library for tests
- pnpm
Folder structure
src/
main.tsx — Vite entrypoint
App.tsx — root component
routes/ — route components, one file per page
components/ — reusable components, organized by feature
hooks/ — reusable hooks (only when shared)
lib/ — utilities and integrations
api/ — API client functions, one file per resource
types/ — shared types
styles/ — global styles
Components
- Functional only. No class components. No
React.FC— type props with an inline interface. - Named exports only. No default exports. Makes find-references and rename refactors reliable.
- One component per file unless a tightly-coupled child component is only used inside the same file.
- Props interfaces inline:
interface ButtonProps {
children: React.ReactNode
onClick: () => void
variant?: 'primary' | 'ghost'
}
export function Button({ children, onClick, variant = 'primary' }: ButtonProps) {
return <button onClick={onClick} className={...}>{children}</button>
}
- Children typed as
React.ReactNode, notJSX.Element - Event handlers typed via
React.ChangeEvent<HTMLInputElement>, notany
Hooks
- Custom hooks live in
src/hooks/and start withuse - A hook lives there only if it's used in 2+ places — otherwise inline it
- Don't pass refs through hook return values unless needed
useCallbackanduseMemoare not free — only use them when measurably needed (memoizing a value that's a child component's prop and feeds aReact.memo'd component, or stabilizing a dep foruseEffect)
State management
- Local state:
useState,useReducerfor complex local flows - Server state: TanStack Query — never store fetched data in
useStateif you'll need to refetch - Global state: avoid by default. If needed, use Zustand (small, simple) over Redux Toolkit. Context for low-frequency shared values (theme, locale).
- Form state:
react-hook-formfor forms with 3+ fields; controlled inputs withuseStatefor trivial ones
Data fetching with TanStack Query
const { data, isLoading, error } = useQuery({
queryKey: ['post', id],
queryFn: () => api.getPost(id),
})
- Query keys: arrays starting with the resource name; include all params that affect the query
- Use
useMutationfor POST/PUT/DELETE; callqueryClient.invalidateQuerieson success - Set
staleTimedeliberately on each query — defaulting to 0 thrashes the network
Patterns to follow
- Composition over abstraction — duplicate twice before extracting
- Push
useStatedown to the component that owns it; lift only when necessary - Use
<Suspense>boundaries around lazy components and data-fetching components - Render lists with stable, content-derived keys (never the array index unless the list is truly static)
- Keep effects small — one effect per concern, not one giant
useEffect
Patterns to avoid
- Class components. Use hooks.
any. Useunknownand narrow.- Default exports. Named only.
- Putting fetched data in
useState+useEffect. Use TanStack Query or Server Actions instead. useEffectfor derived state. If you can compute it from existing state, just compute it in render.useMemofor everything. It's a cost. Profile first.- Inline objects/arrays as props for memoized components. They re-render anyway.
- Mutating state directly. Always create a new object/array.
Testing
- Vitest +
@testing-library/react - Test behavior, not implementation — query by accessible name, not test IDs (unless unavoidable)
- Mock at the boundary (mock
fetch, mock the API client) — not internal helpers - E2E with Playwright for critical paths only
Tooling
pnpm dev— Vite dev serverpnpm build— production buildpnpm test— Vitestpnpm lint— ESLintpnpm typecheck—tsc --noEmit
AI behavioral rules
- Default to functional components and hooks; never propose a class component
- Use named exports; never default exports
- For server data, default to TanStack Query — don't manage server data in
useState - Don't add
useMemo/useCallbackunless you can justify the cost - Don't add
useEffectfor things that can be computed during render - Validate API responses at the boundary (Zod) — don't trust the server's shape
- Run lint, typecheck, and tests before declaring a task complete