dotmd
// 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
    AGENTS.md
  • OpenAI Codex
    AGENTS.md
  • Windsurf
    AGENTS.md
  • Cline
    AGENTS.md
// File Content
AGENTS.md
1# AGENTS.md — React + TypeScript
2
3This file contains your working instructions for this codebase. Follow these
4conventions, workflow rules, and behavioral guidelines on every task.
5
6---
7
8## Quick Reference
9
10| 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` |
21
22Always run `pnpm lint && pnpm typecheck && pnpm test` before committing.
23
24---
25
26## Project Structure
27
28```
29├── src/
30│ ├── app/ # App entry, routing, providers
31│ ├── 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 functions
39│ ├── test/ # Test setup, custom render, MSW mocks
40│ └── styles/
41├── public/
42├── tsconfig.json
43├── vite.config.ts # Or next.config.ts
44└── package.json
45```
46
47`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.
48
49---
50
51## Component Patterns
52
53### Function Components Only
54
55All components are function components with TypeScript props interfaces:
56
57```tsx
58interface OrderCardProps {
59 order: Order;
60 onCancel: (orderId: string) => void;
61 showDetails?: boolean;
62}
63
64export 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 Order
72 </button>
73 </article>
74 );
75}
76```
77
78- 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.
81
82**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.
83
84### Composition Over Configuration
85
86Prefer composition (children, render props) over props-heavy mega-components:
87
88```tsx
89// ✅ Composable
90<Card>
91 <Card.Header><h2>Order #{order.id}</h2></Card.Header>
92 <Card.Body><OrderItems items={order.items} /></Card.Body>
93</Card>
94
95// ❌ Prop soup
96<Card title={`Order #${order.id}`} body={<OrderItems items={order.items} />} />
97```
98
99---
100
101## TypeScript Conventions
102
103### Strict Mode
104
105`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`
108
109### Types for Props, API, and State
110
111```typescript
112// src/types/models.ts
113interface Order {
114 id: string;
115 userId: string;
116 status: OrderStatus;
117 totalCents: number;
118 items: OrderItem[];
119 createdAt: string; // ISO 8601
120}
121
122type OrderStatus = "pending" | "confirmed" | "shipped" | "delivered" | "cancelled";
123```
124
125- 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.
130
131### Discriminated Unions for Async State
132
133```typescript
134type AsyncState<T> =
135 | { status: "idle" }
136 | { status: "loading" }
137 | { status: "success"; data: T }
138 | { status: "error"; error: Error };
139```
140
141Use discriminated unions instead of separate `isLoading` / `error` / `data` booleans. Makes impossible states impossible.
142
143---
144
145## State Management Hierarchy
146
147Use the simplest tool that works. Don't reach for global state by default.
148
149| 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 |
156
157**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.
162
163### Data Fetching with TanStack Query (if present)
164
165Define fetch functions in `api/`, wrap them in query/mutation hooks:
166
167```typescript
168// src/api/orders.ts
169export async function fetchOrders(): Promise<Order[]> {
170 return apiClient<Order[]>("/orders");
171}
172
173// src/hooks/useOrders.ts
174export function useOrders() {
175 return useQuery({ queryKey: ["orders"], queryFn: fetchOrders });
176}
177
178export 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```
186
187---
188
189## Custom Hooks
190
191Extract reusable logic into hooks when two+ components share the same stateful logic.
192
193```typescript
194// src/hooks/useDebounce.ts
195export 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```
204
205- 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.
208
209---
210
211## Accessibility
212
213Accessibility is not optional. Use semantic HTML first — `<nav>`, `<button>`, `<a>` — not `<div onClick>`.
214
215```tsx
216// ✅ Semantic
217<nav aria-label="Main navigation">
218 <ul><li><a href="/dashboard">Dashboard</a></li></ul>
219</nav>
220
221// ❌ Div soup
222<div className="nav"><div onClick={goToDashboard}>Dashboard</div></div>
223```
224
225**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">`.
232
233---
234
235## API Client Layer
236
237All API communication goes through `src/api/`. Components never call `fetch` or `axios` directly.
238
239```typescript
240// src/api/client.ts
241class ApiError extends Error {
242 constructor(public status: number, message: string) {
243 super(message);
244 }
245}
246
247export 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```
259
260- 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`.
263
264---
265
266## Forms
267
268Use a form library (React Hook Form, Formik, or Conform) for anything non-trivial. Use `zod` for schema validation shared between client and server.
269
270```tsx
271import { useForm } from "react-hook-form";
272import { zodResolver } from "@hookform/resolvers/zod";
273import { z } from "zod";
274
275const schema = z.object({ name: z.string().min(1, "Required").max(100) });
276type FormData = z.infer<typeof schema>;
277
278export 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```
292
293Validate client-side for UX, always re-validate on the server. Show inline errors next to fields. Disable submit while submitting.
294
295---
296
297## Testing
298
299### Setup
300
301Tests 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.
302
303### Component Test Example
304
305```typescript
306// src/components/features/orders/OrderList.test.tsx
307import { screen, within } from "@testing-library/react";
308import userEvent from "@testing-library/user-event";
309import { renderWithProviders } from "@/test/render";
310import { OrderList } from "./OrderList";
311
312const 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];
316
317describe("OrderList", () => {
318 it("renders all orders", () => {
319 renderWithProviders(<OrderList orders={mockOrders} onCancel={vi.fn()} />);
320
321 expect(screen.getByText("Order A")).toBeInTheDocument();
322 expect(screen.getByText("Order B")).toBeInTheDocument();
323 });
324
325 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} />);
329
330 const firstOrder = screen.getByText("Order A").closest("article")!;
331 await user.click(within(firstOrder).getByRole("button", { name: /cancel/i }));
332
333 expect(onCancel).toHaveBeenCalledWith("1");
334 });
335
336 it("shows empty state when no orders", () => {
337 renderWithProviders(<OrderList orders={[]} onCancel={vi.fn()} />);
338
339 expect(screen.getByText(/no orders/i)).toBeInTheDocument();
340 });
341});
342```
343
344### Hook Test Example
345
346```typescript
347import { renderHook, act } from "@testing-library/react";
348import { useDebounce } from "@/hooks/useDebounce";
349
350describe("useDebounce", () => {
351 beforeEach(() => vi.useFakeTimers());
352 afterEach(() => vi.useRealTimers());
353
354 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 updated
361 act(() => vi.advanceTimersByTime(300));
362 expect(result.current).toBe("world"); // Updated after delay
363 });
364});
365```
366
367### Testing Rules
368
369- 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.
374
375---
376
377## Error Handling & Performance
378
379- 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.
384
385---
386
387## Code Style
388
389- 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.
393
394---
395
396## Pull Request Checklist
397
3981. `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