dotmd

AGENTS.md — React + TypeScript

Cross-tool AGENTS.md guidance for React + TypeScript codebases with scalable UI and API conventions.

By dotmd TeamCC0Published Feb 19, 2026View source ↗

Install path

Use this file for each supported tool in your project.

  • Cursor: Save as AGENTS.md in your project at AGENTS.md.
  • Claude Code: Save as AGENTS.md in your project at AGENTS.md.
  • OpenAI Codex: Save as AGENTS.md in your project at AGENTS.md.
  • Windsurf: Save as AGENTS.md in your project at AGENTS.md.
  • Cline: Save as AGENTS.md in your project at AGENTS.md.

Configuration

AGENTS.md

1# AGENTS.md — React + TypeScript
2
3## Quick Reference
4
5| Task | Command |
6|---|---|
7| Dev server | `pnpm dev` |
8| Build | `pnpm build` |
9| Lint | `pnpm lint` |
10| Type check | `pnpm typecheck` (`tsc --noEmit`) |
11| Run all tests | `pnpm test` |
12| Run single test | `pnpm test -- src/components/Button.test.tsx` |
13| Test (watch mode) | `pnpm test --watch` |
14| Format | `pnpm format` (Prettier) |
15| Storybook (if present) | `pnpm storybook` |
16
17Always run `pnpm lint && pnpm typecheck && pnpm test` before committing.
18
19---
20
21## Project Structure
22
23```
24├── src/
25│ ├── app/ # App entry, routing, providers
26│ ├── components/
27│ │ ├── ui/ # Reusable primitives (Button, Input, Modal)
28│ │ └── features/ # Feature-specific components (orders/, users/)
29│ ├── hooks/ # Shared custom hooks (useDebounce, useMediaQuery)
30│ ├── api/ # Typed fetch wrappers (client.ts, orders.ts, types.ts)
31│ ├── stores/ # Global state (Zustand, Jotai, or Redux)
32│ ├── types/ # Shared types (models.ts, common.ts)
33│ ├── utils/ # Pure utility functions
34│ ├── test/ # Test setup, custom render, MSW mocks
35│ └── styles/
36├── public/
37├── tsconfig.json
38├── vite.config.ts # Or next.config.ts
39└── package.json
40```
41
42`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.
43
44---
45
46## Component Patterns
47
48### Function Components Only
49
50All components are function components with TypeScript props interfaces:
51
52```tsx
53interface OrderCardProps {
54 order: Order;
55 onCancel: (orderId: string) => void;
56 showDetails?: boolean;
57}
58
59export function OrderCard({ order, onCancel, showDetails = false }: OrderCardProps) {
60 return (
61 <article aria-labelledby={`order-${order.id}`}>
62 <h3 id={`order-${order.id}`}>{order.title}</h3>
63 <p>{formatCurrency(order.totalCents)}</p>
64 {showDetails && <OrderDetails items={order.items} />}
65 <button onClick={() => onCancel(order.id)} type="button">
66 Cancel Order
67 </button>
68 </article>
69 );
70}
71```
72
73- Named exports, not default exports. One component per file.
74- Props interface named `<ComponentName>Props`, defined above the component.
75- No `React.FC`. Use plain function declarations with destructured props.
76
77**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.
78
79### Composition Over Configuration
80
81Prefer composition (children, render props) over props-heavy mega-components:
82
83```tsx
84// ✅ Composable
85<Card>
86 <Card.Header><h2>Order #{order.id}</h2></Card.Header>
87 <Card.Body><OrderItems items={order.items} /></Card.Body>
88</Card>
89
90// ❌ Prop soup
91<Card title={`Order #${order.id}`} body={<OrderItems items={order.items} />} />
92```
93
94---
95
96## TypeScript Conventions
97
98### Strict Mode
99
100`tsconfig.json` must have `"strict": true`. Non-negotiable. This enables:
101- `noImplicitAny`, `strictNullChecks`, `strictFunctionTypes`
102- No `any` without an explanatory comment and `// eslint-disable-next-line`
103
104### Types for Props, API, and State
105
106```typescript
107// src/types/models.ts
108interface Order {
109 id: string;
110 userId: string;
111 status: OrderStatus;
112 totalCents: number;
113 items: OrderItem[];
114 createdAt: string; // ISO 8601
115}
116
117type OrderStatus = "pending" | "confirmed" | "shipped" | "delivered" | "cancelled";
118```
119
120- Use string literal unions, not TypeScript `enum` (tree-shaking issues, runtime overhead).
121- Dates from the API are `string` (ISO 8601). Parse to `Date` only for formatting/comparison.
122- Use `number` for monetary values in cents. Never float dollars.
123- Prefer `interface` for object shapes, `type` for unions and mapped types.
124- Never use `as` to cast away type errors. Fix the types.
125
126### Discriminated Unions for Async State
127
128```typescript
129type AsyncState<T> =
130 | { status: "idle" }
131 | { status: "loading" }
132 | { status: "success"; data: T }
133 | { status: "error"; error: Error };
134```
135
136Use discriminated unions instead of separate `isLoading` / `error` / `data` booleans. Makes impossible states impossible.
137
138---
139
140## State Management Hierarchy
141
142Use the simplest tool that works. Don't reach for global state by default.
143
144| Scope | Tool | Example |
145|---|---|---|
146| Component-local | `useState`, `useReducer` | Form inputs, toggles, accordion open/close |
147| Derived/computed | `useMemo`, computed from props | Filtered lists, formatted values |
148| Server data | TanStack Query, SWR, or RTK Query | API responses, pagination, mutations |
149| Cross-component (narrow) | Context + `useReducer` | Theme, toast notifications, modal stack |
150| Global client state | Zustand, Jotai, or Redux Toolkit | Auth session, user preferences, cart |
151
152**Rules:**
153- Server state (data from your API) is managed by a data-fetching library, not hand-written `useEffect` + `useState`.
154- If using TanStack Query, queries are defined in `api/` alongside the fetch functions — not in components.
155- Global state stores live in `stores/`. One file per store, named by domain.
156- Don't put server-fetched data into Zustand/Redux. Let TanStack Query own the cache.
157
158### Data Fetching with TanStack Query (if present)
159
160Define fetch functions in `api/`, wrap them in query/mutation hooks:
161
162```typescript
163// src/api/orders.ts
164export async function fetchOrders(): Promise<Order[]> {
165 return apiClient<Order[]>("/orders");
166}
167
168// src/hooks/useOrders.ts
169export function useOrders() {
170 return useQuery({ queryKey: ["orders"], queryFn: fetchOrders });
171}
172
173export function useCancelOrder() {
174 const qc = useQueryClient();
175 return useMutation({
176 mutationFn: (id: string) => apiClient(`/orders/${id}/cancel`, { method: "POST" }),
177 onSuccess: () => qc.invalidateQueries({ queryKey: ["orders"] }),
178 });
179}
180```
181
182---
183
184## Custom Hooks
185
186Extract reusable logic into hooks when two+ components share the same stateful logic.
187
188```typescript
189// src/hooks/useDebounce.ts
190export function useDebounce<T>(value: T, delayMs: number): T {
191 const [debounced, setDebounced] = useState(value);
192 useEffect(() => {
193 const timer = setTimeout(() => setDebounced(value), delayMs);
194 return () => clearTimeout(timer);
195 }, [value, delayMs]);
196 return debounced;
197}
198```
199
200- Hooks start with `use`. One hook per file in `hooks/`. Feature-specific hooks can live alongside their components.
201- Hooks must not return JSX — if it returns JSX, it's a component.
202- Clean up side effects in `useEffect` return functions. Missing cleanup = memory leak.
203
204---
205
206## Accessibility
207
208Accessibility is not optional. Use semantic HTML first — `<nav>`, `<button>`, `<a>` — not `<div onClick>`.
209
210```tsx
211// ✅ Semantic
212<nav aria-label="Main navigation">
213 <ul><li><a href="/dashboard">Dashboard</a></li></ul>
214</nav>
215
216// ❌ Div soup
217<div className="nav"><div onClick={goToDashboard}>Dashboard</div></div>
218```
219
220**Requirements:**
221- Every interactive element is keyboard accessible. `type="button"` on non-submit buttons.
222- Every `<img>` has meaningful `alt` (or `alt=""` + `aria-hidden="true"` for decorative).
223- Form inputs have `<label>` elements (via `htmlFor`), not just placeholder text.
224- Color is not the only indicator. Error states use icons or text, not just red.
225- Focus management: modal opens → focus moves in. Modal closes → focus returns to trigger.
226- ARIA only when native semantics are insufficient. Prefer `<button>` over `<div role="button">`.
227
228---
229
230## API Client Layer
231
232All API communication goes through `src/api/`. Components never call `fetch` or `axios` directly.
233
234```typescript
235// src/api/client.ts
236class ApiError extends Error {
237 constructor(public status: number, message: string) {
238 super(message);
239 }
240}
241
242export async function apiClient<T>(path: string, options?: RequestInit): Promise<T> {
243 const response = await fetch(`${import.meta.env.VITE_API_URL ?? "/api"}${path}`, {
244 ...options,
245 headers: { "Content-Type": "application/json", ...options?.headers },
246 });
247 if (!response.ok) {
248 const body = await response.json().catch(() => ({}));
249 throw new ApiError(response.status, body.message ?? "Request failed");
250 }
251 return response.json() as Promise<T>;
252}
253```
254
255- Auth token injection happens in the client, not at each call site.
256- API response types live in `api/types.ts`. Domain types in `types/models.ts`. Map between them at the API layer.
257- Type the return values of every API function. No `any`.
258
259---
260
261## Forms
262
263Use a form library (React Hook Form, Formik, or Conform) for anything non-trivial. Use `zod` for schema validation shared between client and server.
264
265```tsx
266import { useForm } from "react-hook-form";
267import { zodResolver } from "@hookform/resolvers/zod";
268import { z } from "zod";
269
270const schema = z.object({ name: z.string().min(1, "Required").max(100) });
271type FormData = z.infer<typeof schema>;
272
273export function CreateProjectForm({ onSubmit }: { onSubmit: (d: FormData) => void }) {
274 const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
275 resolver: zodResolver(schema),
276 });
277 return (
278 <form onSubmit={handleSubmit(onSubmit)} noValidate>
279 <label htmlFor="name">Name</label>
280 <input id="name" {...register("name")} aria-invalid={!!errors.name} aria-describedby="name-err" />
281 {errors.name && <p id="name-err" role="alert">{errors.name.message}</p>}
282 <button type="submit" disabled={isSubmitting}>{isSubmitting ? "Creating…" : "Create"}</button>
283 </form>
284 );
285}
286```
287
288Validate client-side for UX, always re-validate on the server. Show inline errors next to fields. Disable submit while submitting.
289
290---
291
292## Testing
293
294### Setup
295
296Tests 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.
297
298### Component Test Example
299
300```typescript
301// src/components/features/orders/OrderList.test.tsx
302import { screen, within } from "@testing-library/react";
303import userEvent from "@testing-library/user-event";
304import { renderWithProviders } from "@/test/render";
305import { OrderList } from "./OrderList";
306
307const mockOrders = [
308 { id: "1", title: "Order A", status: "pending" as const, totalCents: 2500, items: [] },
309 { id: "2", title: "Order B", status: "shipped" as const, totalCents: 5000, items: [] },
310];
311
312describe("OrderList", () => {
313 it("renders all orders", () => {
314 renderWithProviders(<OrderList orders={mockOrders} onCancel={vi.fn()} />);
315
316 expect(screen.getByText("Order A")).toBeInTheDocument();
317 expect(screen.getByText("Order B")).toBeInTheDocument();
318 });
319
320 it("calls onCancel with the order id when cancel is clicked", async () => {
321 const user = userEvent.setup();
322 const onCancel = vi.fn();
323 renderWithProviders(<OrderList orders={mockOrders} onCancel={onCancel} />);
324
325 const firstOrder = screen.getByText("Order A").closest("article")!;
326 await user.click(within(firstOrder).getByRole("button", { name: /cancel/i }));
327
328 expect(onCancel).toHaveBeenCalledWith("1");
329 });
330
331 it("shows empty state when no orders", () => {
332 renderWithProviders(<OrderList orders={[]} onCancel={vi.fn()} />);
333
334 expect(screen.getByText(/no orders/i)).toBeInTheDocument();
335 });
336});
337```
338
339### Hook Test Example
340
341```typescript
342import { renderHook, act } from "@testing-library/react";
343import { useDebounce } from "@/hooks/useDebounce";
344
345describe("useDebounce", () => {
346 beforeEach(() => vi.useFakeTimers());
347 afterEach(() => vi.useRealTimers());
348
349 it("updates the value after the delay", () => {
350 const { result, rerender } = renderHook(
351 ({ value }) => useDebounce(value, 300),
352 { initialProps: { value: "hello" } }
353 );
354 rerender({ value: "world" });
355 expect(result.current).toBe("hello"); // Not yet updated
356 act(() => vi.advanceTimersByTime(300));
357 expect(result.current).toBe("world"); // Updated after delay
358 });
359});
360```
361
362### Testing Rules
363
364- Query by role (`getByRole`), label (`getByLabelText`), or text (`getByText`). Never by CSS class or test ID unless no better option.
365- Use `userEvent` over `fireEvent` — it simulates real interactions (focus, hover, keyboard).
366- Mock API calls with MSW at the network level, not by mocking `fetch`.
367- Test what the user sees, not internal state. Don't assert on `useState` values.
368- Each test file mirrors its component: `Button.tsx` → `Button.test.tsx`, same directory.
369
370---
371
372## Error Handling & Performance
373
374- Use Error Boundaries around feature areas for unexpected render errors. Not just at the app root.
375- API errors are typed (`ApiError` with status and message). Never swallow into generic catches.
376- Form validation errors go inline. Network errors go in toast/banner notifications. Never show raw stacks.
377- Lazy load routes with `React.lazy()` + `Suspense`. Virtualize long lists (>100 items) with `@tanstack/react-virtual`.
378- Only use `useMemo`/`useCallback` when you've measured a performance issue. Don't memoize everything.
379
380---
381
382## Code Style
383
384- Follow the ESLint and Prettier config in the repo. Don't override project rules.
385- Imports: framework → third-party → local, separated by blank lines. Use path aliases (`@/`).
386- File naming: PascalCase for components (`OrderCard.tsx`), camelCase for hooks/utils (`useDebounce.ts`).
387- No `console.log` in committed code. CSS: Tailwind utility classes or CSS Modules — no inline styles except truly dynamic values.
388
389---
390
391## Pull Request Checklist
392
3931. `pnpm lint && pnpm typecheck` passes with zero issues.
3942. `pnpm test` passes. No skipped tests without a reason.
3953. New components have tests covering primary interactions.
3964. Accessibility: interactive elements are keyboard accessible, images have alt text, forms have labels.
3975. No `any` types without an explanatory comment.
3986. No `console.log` or debugging artifacts.
3997. API changes are backwards compatible or coordinated with the backend.
4008. New routes are lazy-loaded.
401

Community feedback

0 found this helpful

Works with: