AGENTS.md — React + TypeScript
Cross-tool AGENTS.md guidance for React + TypeScript codebases with scalable UI and API conventions.
Install path
Use this file for each supported tool in your project.
- Cursor: Save as
AGENTS.mdin your project atAGENTS.md. - Claude Code: Save as
AGENTS.mdin your project atAGENTS.md. - OpenAI Codex: Save as
AGENTS.mdin your project atAGENTS.md. - Windsurf: Save as
AGENTS.mdin your project atAGENTS.md. - Cline: Save as
AGENTS.mdin your project atAGENTS.md.
Configuration
AGENTS.md
1# AGENTS.md — React + TypeScript23## Quick Reference45| 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` |1617Always run `pnpm lint && pnpm typecheck && pnpm test` before committing.1819---2021## Project Structure2223```24├── src/25│ ├── app/ # App entry, routing, providers26│ ├── 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 functions34│ ├── test/ # Test setup, custom render, MSW mocks35│ └── styles/36├── public/37├── tsconfig.json38├── vite.config.ts # Or next.config.ts39└── package.json40```4142`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.4344---4546## Component Patterns4748### Function Components Only4950All components are function components with TypeScript props interfaces:5152```tsx53interface OrderCardProps {54 order: Order;55 onCancel: (orderId: string) => void;56 showDetails?: boolean;57}5859export 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 Order67 </button>68 </article>69 );70}71```7273- 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.7677**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.7879### Composition Over Configuration8081Prefer composition (children, render props) over props-heavy mega-components:8283```tsx84// ✅ Composable85<Card>86 <Card.Header><h2>Order #{order.id}</h2></Card.Header>87 <Card.Body><OrderItems items={order.items} /></Card.Body>88</Card>8990// ❌ Prop soup91<Card title={`Order #${order.id}`} body={<OrderItems items={order.items} />} />92```9394---9596## TypeScript Conventions9798### Strict Mode99100`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`103104### Types for Props, API, and State105106```typescript107// src/types/models.ts108interface Order {109 id: string;110 userId: string;111 status: OrderStatus;112 totalCents: number;113 items: OrderItem[];114 createdAt: string; // ISO 8601115}116117type OrderStatus = "pending" | "confirmed" | "shipped" | "delivered" | "cancelled";118```119120- 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.125126### Discriminated Unions for Async State127128```typescript129type AsyncState<T> =130 | { status: "idle" }131 | { status: "loading" }132 | { status: "success"; data: T }133 | { status: "error"; error: Error };134```135136Use discriminated unions instead of separate `isLoading` / `error` / `data` booleans. Makes impossible states impossible.137138---139140## State Management Hierarchy141142Use the simplest tool that works. Don't reach for global state by default.143144| 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 |151152**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.157158### Data Fetching with TanStack Query (if present)159160Define fetch functions in `api/`, wrap them in query/mutation hooks:161162```typescript163// src/api/orders.ts164export async function fetchOrders(): Promise<Order[]> {165 return apiClient<Order[]>("/orders");166}167168// src/hooks/useOrders.ts169export function useOrders() {170 return useQuery({ queryKey: ["orders"], queryFn: fetchOrders });171}172173export 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```181182---183184## Custom Hooks185186Extract reusable logic into hooks when two+ components share the same stateful logic.187188```typescript189// src/hooks/useDebounce.ts190export 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```199200- 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.203204---205206## Accessibility207208Accessibility is not optional. Use semantic HTML first — `<nav>`, `<button>`, `<a>` — not `<div onClick>`.209210```tsx211// ✅ Semantic212<nav aria-label="Main navigation">213 <ul><li><a href="/dashboard">Dashboard</a></li></ul>214</nav>215216// ❌ Div soup217<div className="nav"><div onClick={goToDashboard}>Dashboard</div></div>218```219220**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">`.227228---229230## API Client Layer231232All API communication goes through `src/api/`. Components never call `fetch` or `axios` directly.233234```typescript235// src/api/client.ts236class ApiError extends Error {237 constructor(public status: number, message: string) {238 super(message);239 }240}241242export 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```254255- 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`.258259---260261## Forms262263Use a form library (React Hook Form, Formik, or Conform) for anything non-trivial. Use `zod` for schema validation shared between client and server.264265```tsx266import { useForm } from "react-hook-form";267import { zodResolver } from "@hookform/resolvers/zod";268import { z } from "zod";269270const schema = z.object({ name: z.string().min(1, "Required").max(100) });271type FormData = z.infer<typeof schema>;272273export 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```287288Validate client-side for UX, always re-validate on the server. Show inline errors next to fields. Disable submit while submitting.289290---291292## Testing293294### Setup295296Tests 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.297298### Component Test Example299300```typescript301// src/components/features/orders/OrderList.test.tsx302import { screen, within } from "@testing-library/react";303import userEvent from "@testing-library/user-event";304import { renderWithProviders } from "@/test/render";305import { OrderList } from "./OrderList";306307const 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];311312describe("OrderList", () => {313 it("renders all orders", () => {314 renderWithProviders(<OrderList orders={mockOrders} onCancel={vi.fn()} />);315316 expect(screen.getByText("Order A")).toBeInTheDocument();317 expect(screen.getByText("Order B")).toBeInTheDocument();318 });319320 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} />);324325 const firstOrder = screen.getByText("Order A").closest("article")!;326 await user.click(within(firstOrder).getByRole("button", { name: /cancel/i }));327328 expect(onCancel).toHaveBeenCalledWith("1");329 });330331 it("shows empty state when no orders", () => {332 renderWithProviders(<OrderList orders={[]} onCancel={vi.fn()} />);333334 expect(screen.getByText(/no orders/i)).toBeInTheDocument();335 });336});337```338339### Hook Test Example340341```typescript342import { renderHook, act } from "@testing-library/react";343import { useDebounce } from "@/hooks/useDebounce";344345describe("useDebounce", () => {346 beforeEach(() => vi.useFakeTimers());347 afterEach(() => vi.useRealTimers());348349 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 updated356 act(() => vi.advanceTimersByTime(300));357 expect(result.current).toBe("world"); // Updated after delay358 });359});360```361362### Testing Rules363364- 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.369370---371372## Error Handling & Performance373374- 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.379380---381382## Code Style383384- 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.388389---390391## Pull Request Checklist3923931. `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: