// Config Record
>React + TypeScript
Cross-tool AGENTS.md guidance for React + TypeScript codebases with scalable UI and API conventions.
author:
dotmd Team
license:CC0
published:Feb 19, 2026
// Installation
>Add this file to your project repository:
- Cursor--path=
AGENTS.md - OpenAI Codex--path=
AGENTS.md - Windsurf--path=
AGENTS.md - Cline--path=
AGENTS.md
// File Content
AGENTS.md
1# AGENTS.md — React + TypeScript23This file contains your working instructions for this codebase. Follow these4conventions, workflow rules, and behavioral guidelines on every task.56---78## Quick Reference910| Task | Command |11|---|---|12| Dev server | `pnpm dev` |13| Build | `pnpm build` |14| Lint | `pnpm lint` |15| Type check | `pnpm typecheck` (`tsc --noEmit`) |16| Run all tests | `pnpm test` |17| Run single test | `pnpm test -- src/components/Button.test.tsx` |18| Test (watch mode) | `pnpm test --watch` |19| Format | `pnpm format` (Prettier) |20| Storybook (if present) | `pnpm storybook` |2122Always run `pnpm lint && pnpm typecheck && pnpm test` before committing.2324---2526## Project Structure2728```29├── src/30│ ├── app/ # App entry, routing, providers31│ ├── components/32│ │ ├── ui/ # Reusable primitives (Button, Input, Modal)33│ │ └── features/ # Feature-specific components (orders/, users/)34│ ├── hooks/ # Shared custom hooks (useDebounce, useMediaQuery)35│ ├── api/ # Typed fetch wrappers (client.ts, orders.ts, types.ts)36│ ├── stores/ # Global state (Zustand, Jotai, or Redux)37│ ├── types/ # Shared types (models.ts, common.ts)38│ ├── utils/ # Pure utility functions39│ ├── test/ # Test setup, custom render, MSW mocks40│ └── styles/41├── public/42├── tsconfig.json43├── vite.config.ts # Or next.config.ts44└── package.json45```4647`components/ui/` holds reusable, design-system-level primitives. `components/features/` holds domain-specific compositions. Don't put business logic in either — that belongs in hooks, stores, or the API layer.4849---5051## Component Patterns5253### Function Components Only5455All components are function components with TypeScript props interfaces:5657```tsx58interface OrderCardProps {59 order: Order;60 onCancel: (orderId: string) => void;61 showDetails?: boolean;62}6364export function OrderCard({ order, onCancel, showDetails = false }: OrderCardProps) {65 return (66 <article aria-labelledby={`order-${order.id}`}>67 <h3 id={`order-${order.id}`}>{order.title}</h3>68 <p>{formatCurrency(order.totalCents)}</p>69 {showDetails && <OrderDetails items={order.items} />}70 <button onClick={() => onCancel(order.id)} type="button">71 Cancel Order72 </button>73 </article>74 );75}76```7778- Named exports, not default exports. One component per file.79- Props interface named `<ComponentName>Props`, defined above the component.80- No `React.FC`. Use plain function declarations with destructured props.8182**Component hierarchy:** Page/Route (top-level, data fetching) → Feature components (`components/features/`, domain-specific) → UI primitives (`components/ui/`, stateless, no domain knowledge). UI primitives must not import feature components.8384### Composition Over Configuration8586Prefer composition (children, render props) over props-heavy mega-components:8788```tsx89// ✅ Composable90<Card>91 <Card.Header><h2>Order #{order.id}</h2></Card.Header>92 <Card.Body><OrderItems items={order.items} /></Card.Body>93</Card>9495// ❌ Prop soup96<Card title={`Order #${order.id}`} body={<OrderItems items={order.items} />} />97```9899---100101## TypeScript Conventions102103### Strict Mode104105`tsconfig.json` must have `"strict": true`. Non-negotiable. This enables:106- `noImplicitAny`, `strictNullChecks`, `strictFunctionTypes`107- No `any` without an explanatory comment and `// eslint-disable-next-line`108109### Types for Props, API, and State110111```typescript112// src/types/models.ts113interface Order {114 id: string;115 userId: string;116 status: OrderStatus;117 totalCents: number;118 items: OrderItem[];119 createdAt: string; // ISO 8601120}121122type OrderStatus = "pending" | "confirmed" | "shipped" | "delivered" | "cancelled";123```124125- Use string literal unions, not TypeScript `enum` (tree-shaking issues, runtime overhead).126- Dates from the API are `string` (ISO 8601). Parse to `Date` only for formatting/comparison.127- Use `number` for monetary values in cents. Never float dollars.128- Prefer `interface` for object shapes, `type` for unions and mapped types.129- Never use `as` to cast away type errors. Fix the types.130131### Discriminated Unions for Async State132133```typescript134type AsyncState<T> =135 | { status: "idle" }136 | { status: "loading" }137 | { status: "success"; data: T }138 | { status: "error"; error: Error };139```140141Use discriminated unions instead of separate `isLoading` / `error` / `data` booleans. Makes impossible states impossible.142143---144145## State Management Hierarchy146147Use the simplest tool that works. Don't reach for global state by default.148149| Scope | Tool | Example |150|---|---|---|151| Component-local | `useState`, `useReducer` | Form inputs, toggles, accordion open/close |152| Derived/computed | `useMemo`, computed from props | Filtered lists, formatted values |153| Server data | TanStack Query, SWR, or RTK Query | API responses, pagination, mutations |154| Cross-component (narrow) | Context + `useReducer` | Theme, toast notifications, modal stack |155| Global client state | Zustand, Jotai, or Redux Toolkit | Auth session, user preferences, cart |156157**Rules:**158- Server state (data from your API) is managed by a data-fetching library, not hand-written `useEffect` + `useState`.159- If using TanStack Query, queries are defined in `api/` alongside the fetch functions — not in components.160- Global state stores live in `stores/`. One file per store, named by domain.161- Don't put server-fetched data into Zustand/Redux. Let TanStack Query own the cache.162163### Data Fetching with TanStack Query (if present)164165Define fetch functions in `api/`, wrap them in query/mutation hooks:166167```typescript168// src/api/orders.ts169export async function fetchOrders(): Promise<Order[]> {170 return apiClient<Order[]>("/orders");171}172173// src/hooks/useOrders.ts174export function useOrders() {175 return useQuery({ queryKey: ["orders"], queryFn: fetchOrders });176}177178export function useCancelOrder() {179 const qc = useQueryClient();180 return useMutation({181 mutationFn: (id: string) => apiClient(`/orders/${id}/cancel`, { method: "POST" }),182 onSuccess: () => qc.invalidateQueries({ queryKey: ["orders"] }),183 });184}185```186187---188189## Custom Hooks190191Extract reusable logic into hooks when two+ components share the same stateful logic.192193```typescript194// src/hooks/useDebounce.ts195export function useDebounce<T>(value: T, delayMs: number): T {196 const [debounced, setDebounced] = useState(value);197 useEffect(() => {198 const timer = setTimeout(() => setDebounced(value), delayMs);199 return () => clearTimeout(timer);200 }, [value, delayMs]);201 return debounced;202}203```204205- Hooks start with `use`. One hook per file in `hooks/`. Feature-specific hooks can live alongside their components.206- Hooks must not return JSX — if it returns JSX, it's a component.207- Clean up side effects in `useEffect` return functions. Missing cleanup = memory leak.208209---210211## Accessibility212213Accessibility is not optional. Use semantic HTML first — `<nav>`, `<button>`, `<a>` — not `<div onClick>`.214215```tsx216// ✅ Semantic217<nav aria-label="Main navigation">218 <ul><li><a href="/dashboard">Dashboard</a></li></ul>219</nav>220221// ❌ Div soup222<div className="nav"><div onClick={goToDashboard}>Dashboard</div></div>223```224225**Requirements:**226- Every interactive element is keyboard accessible. `type="button"` on non-submit buttons.227- Every `<img>` has meaningful `alt` (or `alt=""` + `aria-hidden="true"` for decorative).228- Form inputs have `<label>` elements (via `htmlFor`), not just placeholder text.229- Color is not the only indicator. Error states use icons or text, not just red.230- Focus management: modal opens → focus moves in. Modal closes → focus returns to trigger.231- ARIA only when native semantics are insufficient. Prefer `<button>` over `<div role="button">`.232233---234235## API Client Layer236237All API communication goes through `src/api/`. Components never call `fetch` or `axios` directly.238239```typescript240// src/api/client.ts241class ApiError extends Error {242 constructor(public status: number, message: string) {243 super(message);244 }245}246247export async function apiClient<T>(path: string, options?: RequestInit): Promise<T> {248 const response = await fetch(`${import.meta.env.VITE_API_URL ?? "/api"}${path}`, {249 ...options,250 headers: { "Content-Type": "application/json", ...options?.headers },251 });252 if (!response.ok) {253 const body = await response.json().catch(() => ({}));254 throw new ApiError(response.status, body.message ?? "Request failed");255 }256 return response.json() as Promise<T>;257}258```259260- Auth token injection happens in the client, not at each call site.261- API response types live in `api/types.ts`. Domain types in `types/models.ts`. Map between them at the API layer.262- Type the return values of every API function. No `any`.263264---265266## Forms267268Use a form library (React Hook Form, Formik, or Conform) for anything non-trivial. Use `zod` for schema validation shared between client and server.269270```tsx271import { useForm } from "react-hook-form";272import { zodResolver } from "@hookform/resolvers/zod";273import { z } from "zod";274275const schema = z.object({ name: z.string().min(1, "Required").max(100) });276type FormData = z.infer<typeof schema>;277278export function CreateProjectForm({ onSubmit }: { onSubmit: (d: FormData) => void }) {279 const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({280 resolver: zodResolver(schema),281 });282 return (283 <form onSubmit={handleSubmit(onSubmit)} noValidate>284 <label htmlFor="name">Name</label>285 <input id="name" {...register("name")} aria-invalid={!!errors.name} aria-describedby="name-err" />286 {errors.name && <p id="name-err" role="alert">{errors.name.message}</p>}287 <button type="submit" disabled={isSubmitting}>{isSubmitting ? "Creating…" : "Create"}</button>288 </form>289 );290}291```292293Validate client-side for UX, always re-validate on the server. Show inline errors next to fields. Disable submit while submitting.294295---296297## Testing298299### Setup300301Tests use Vitest (or Jest) with React Testing Library. Test behavior, not implementation. Create a custom `renderWithProviders` in `src/test/render.tsx` that wraps components with `QueryClientProvider` (retry: false) and any other global providers.302303### Component Test Example304305```typescript306// src/components/features/orders/OrderList.test.tsx307import { screen, within } from "@testing-library/react";308import userEvent from "@testing-library/user-event";309import { renderWithProviders } from "@/test/render";310import { OrderList } from "./OrderList";311312const mockOrders = [313 { id: "1", title: "Order A", status: "pending" as const, totalCents: 2500, items: [] },314 { id: "2", title: "Order B", status: "shipped" as const, totalCents: 5000, items: [] },315];316317describe("OrderList", () => {318 it("renders all orders", () => {319 renderWithProviders(<OrderList orders={mockOrders} onCancel={vi.fn()} />);320321 expect(screen.getByText("Order A")).toBeInTheDocument();322 expect(screen.getByText("Order B")).toBeInTheDocument();323 });324325 it("calls onCancel with the order id when cancel is clicked", async () => {326 const user = userEvent.setup();327 const onCancel = vi.fn();328 renderWithProviders(<OrderList orders={mockOrders} onCancel={onCancel} />);329330 const firstOrder = screen.getByText("Order A").closest("article")!;331 await user.click(within(firstOrder).getByRole("button", { name: /cancel/i }));332333 expect(onCancel).toHaveBeenCalledWith("1");334 });335336 it("shows empty state when no orders", () => {337 renderWithProviders(<OrderList orders={[]} onCancel={vi.fn()} />);338339 expect(screen.getByText(/no orders/i)).toBeInTheDocument();340 });341});342```343344### Hook Test Example345346```typescript347import { renderHook, act } from "@testing-library/react";348import { useDebounce } from "@/hooks/useDebounce";349350describe("useDebounce", () => {351 beforeEach(() => vi.useFakeTimers());352 afterEach(() => vi.useRealTimers());353354 it("updates the value after the delay", () => {355 const { result, rerender } = renderHook(356 ({ value }) => useDebounce(value, 300),357 { initialProps: { value: "hello" } }358 );359 rerender({ value: "world" });360 expect(result.current).toBe("hello"); // Not yet updated361 act(() => vi.advanceTimersByTime(300));362 expect(result.current).toBe("world"); // Updated after delay363 });364});365```366367### Testing Rules368369- Query by role (`getByRole`), label (`getByLabelText`), or text (`getByText`). Never by CSS class or test ID unless no better option.370- Use `userEvent` over `fireEvent` — it simulates real interactions (focus, hover, keyboard).371- Mock API calls with MSW at the network level, not by mocking `fetch`.372- Test what the user sees, not internal state. Don't assert on `useState` values.373- Each test file mirrors its component: `Button.tsx` → `Button.test.tsx`, same directory.374375---376377## Error Handling & Performance378379- Use Error Boundaries around feature areas for unexpected render errors. Not just at the app root.380- API errors are typed (`ApiError` with status and message). Never swallow into generic catches.381- Form validation errors go inline. Network errors go in toast/banner notifications. Never show raw stacks.382- Lazy load routes with `React.lazy()` + `Suspense`. Virtualize long lists (>100 items) with `@tanstack/react-virtual`.383- Only use `useMemo`/`useCallback` when you've measured a performance issue. Don't memoize everything.384385---386387## Code Style388389- Follow the ESLint and Prettier config in the repo. Don't override project rules.390- Imports: framework → third-party → local, separated by blank lines. Use path aliases (`@/`).391- File naming: PascalCase for components (`OrderCard.tsx`), camelCase for hooks/utils (`useDebounce.ts`).392- No `console.log` in committed code. CSS: Tailwind utility classes or CSS Modules — no inline styles except truly dynamic values.393394---395396## Pull Request Checklist3973981. `pnpm lint && pnpm typecheck` passes with zero issues.3992. `pnpm test` passes. No skipped tests without a reason.4003. New components have tests covering primary interactions.4014. Accessibility: interactive elements are keyboard accessible, images have alt text, forms have labels.4025. No `any` types without an explanatory comment.4036. No `console.log` or debugging artifacts.4047. API changes are backwards compatible or coordinated with the backend.4058. New routes are lazy-loaded.406