React Idioms and Patterns
Core Philosophy
React 19+ rewards composition, hooks, and server-aware patterns. Idiomatic React = functional, performant, accessible. Prefer co-located features, custom hooks for logic reuse, and server state libraries over hand-rolled fetch logic.
Scope: This file covers React-specific coding idioms for components, hooks, state, routing, and forms. For TypeScript type system patterns, see
@.agents/skills/typescript-idioms/SKILL.md. For file and folder layout, seereferences/project-structure.md(and the shared@.agents/skills/frontend-design/references/frontend-layout.md). For general frontend design, see@.agents/skills/frontend-design/SKILL.md.Loading guard: If the project uses Next.js (App Router —
app/dir ornext.config.*), load@.agents/skills/nextjs-idioms/SKILL.mdinstead of this skill for App-Router-specific patterns. This skill still applies to client components and pure-React (Vite) SPAs.
When to Load References
Load these before writing code in the matching context — not after.
| Situation | Reference to Load |
|---|---|
| Starting a React (Vite) project or reviewing file layout | references/project-structure.md + @.agents/skills/frontend-design/references/frontend-layout.md |
| TypeScript type system, async, Zod, error types | @.agents/skills/typescript-idioms/SKILL.md (always co-load) |
| Zod schemas / boundary validation | @.agents/skills/typescript-idioms/references/zod-patterns.md |
| Async / I/O / coercion pitfalls | @.agents/skills/typescript-idioms/references/ts-patterns-and-anti-patterns.md |
| Next.js App Router (RSC, Server Actions, caching) | @.agents/skills/nextjs-idioms/SKILL.md (use that skill instead for Next projects) |
Component Patterns
Functional components only — no class components in new code.
Composition over inheritance:
// ✅ Compound components <Card> <Card.Header>{title}</Card.Header> <Card.Body>{children}</Card.Body> </Card>Error boundaries for graceful failure — wrap feature subtrees to catch render errors.
Render props for flexible, headless composition:
<DataLoader url="/api/tasks"> {({ data, isLoading, error }) => { if (isLoading) return <Skeleton />; if (error) return <ErrorMessage error={error} />; return <TaskList tasks={data} />; }} </DataLoader>Props typing — always explicit:
// ✅ Typed props with defaults interface TaskCardProps { task: Task; onComplete?: (taskId: string) => void; variant?: 'compact' | 'expanded'; } export function TaskCard({ task, onComplete, variant = 'compact' }: TaskCardProps) { // ... }One concern per component — if a component exceeds ~100 JSX lines, extract a sub-component.
Hooks
Custom hooks for reusable logic:
function useTask(id: string) { const { data, error, isLoading } = useQuery({ queryKey: ['task', id], queryFn: () => taskApi.getTask(id), }); return { task: data, error, isLoading }; }useMemo/useCallbackonly for measured performance issues — not by default.useEffectcleanup — always return cleanup function for subscriptions:useEffect(() => { const controller = new AbortController(); fetchTasks(controller.signal).then(setTasks); return () => controller.abort(); // ✅ Cleanup on unmount }, []);useReffor values that don't trigger re-renders:// ✅ Timer ref — doesn't cause re-render const timerRef = useRef<ReturnType<typeof setInterval>>(); useEffect(() => { timerRef.current = setInterval(pollStatus, 5000); return () => clearInterval(timerRef.current); }, []);
React 19 Patterns
use()hook — read resources, promises, and context directly in render:// ✅ Read a promise during render (replaces useEffect + useState) function TaskDetail({ taskPromise }: { taskPromise: Promise<Task> }) { const task = use(taskPromise); return <h1>{task.title}</h1>; } // ✅ Read context without useContext function TaskActions() { const theme = use(ThemeContext); return <button className={theme.primaryBtn}>Save</button>; }useActionStatefor form actions (replacesuseFormState):// ✅ Server-aware form with pending state async function createTask(_prev: State, formData: FormData) { const result = await api.createTask(Object.fromEntries(formData)); return result.error ? { error: result.error } : { success: true }; } function TaskForm() { const [state, formAction, isPending] = useActionState(createTask, { error: null }); return ( <form action={formAction}> <input name="title" required /> {state.error && <p className="error">{state.error}</p>} <button disabled={isPending}>{isPending ? 'Saving…' : 'Create'}</button> </form> ); }useOptimisticfor instant UI feedback:const [optimisticTasks, addOptimistic] = useOptimistic( tasks, (state, newTask: Task) => [...state, newTask], ); // Call addOptimistic(tempTask) before await api.createTask(tempTask)<form action={fn}>for progressive enhancement — works before JS loads (seeuseActionStateexample above).
Form Handling
React Hook Form + Zod for validated forms:
import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; const taskSchema = z.object({ title: z.string().min(1, 'Title is required').max(200), priority: z.enum(['low', 'medium', 'high']), }); type TaskFormData = z.infer<typeof taskSchema>; function TaskForm({ onSubmit }: { onSubmit: (data: TaskFormData) => Promise<void> }) { const { register, handleSubmit, formState: { errors } } = useForm<TaskFormData>({ resolver: zodResolver(taskSchema), }); return ( <form <input {...register('title')} /> {errors.title && <p>{errors.title.message}</p>} <button type="submit">Create</button> </form> ); }Controlled vs uncontrolled decision:
- Use uncontrolled (
register) for simple forms — better performance, less boilerplate - Use controlled (
Controller) when the UI must react to every keystroke (live previews, dependent fields)
- Use uncontrolled (
Routing
React Router 7 data patterns — loaders and actions:
// ✅ Route-level data loading export async function loader({ params }: LoaderFunctionArgs) { return taskApi.getTask(params.id!); } export function TaskPage() { const task = useLoaderData<typeof loader>(); return <TaskDetail task={task} />; }TanStack Router for type-safe routes:
const taskRoute = createRoute({ getParentRoute: () => rootRoute, path: '/tasks/$taskId', loader: ({ params }) => taskApi.getTask(params.taskId), component: TaskPage, });Route-level code splitting — always lazy-load route components with
React.lazy+Suspense(see Performance section).
State Management
Decision tree:
useState→useContext→ Zustand → TanStack Query (for server state)
- Local state first (
useState), lift only when shared by siblings. - Server state: TanStack Query — never in global state:
// ✅ Server state managed by TanStack Query function useTasks() { return useQuery({ queryKey: ['tasks'], queryFn: () => taskApi.getTasks(), staleTime: 5 * 60 * 1000, }); } - Client state: Context for small/infrequent updates, Zustand/Jotai for complex/frequent:
// ✅ features/task/store/task.store.ts — Zustand for UI-only state import { create } from 'zustand'; interface TaskUIState { selectedId: string | null; filter: 'all' | 'active' | 'done'; selectTask: (id: string | null) => void; setFilter: (f: TaskUIState['filter']) => void; } export const useTaskUIStore = create<TaskUIState>((set) => ({ selectedId: null, filter: 'all', selectTask: (id) => set({ selectedId: id }), setFilter: (filter) => set({ filter }), })); // Usage — client UI state only; server data stays in TanStack Query function TaskToolbar() { const { filter, setFilter } = useTaskUIStore(); return <FilterBar value={filter} />; } - I/O isolation — abstract API behind an interface for testability:
// ✅ features/task/api/task.api.ts — interface export interface TaskAPI { getTasks(): Promise<Task[]>; createTask(data: CreateTaskDTO): Promise<Task>; } // ✅ features/task/api/task.api.backend.ts — production (implements TaskAPI with fetch) // ✅ features/task/api/task.api.mock.ts — test (implements TaskAPI with in-memory data)
Error Handling
For universal error handling principles, see
.agents/rules/error-handling-principles.md.
Error boundaries for component tree errors — use
react-error-boundaryor a custom class component:// ✅ Wrap feature subtrees, log in componentDidCatch <ErrorBoundary fallback={<ErrorMessage />}> <TaskList /> </ErrorBoundary>TanStack Query — use
retry,isError, anderrorfrom query result (see State Management).Log errors in
componentDidCatchwithcorrelationIdandcomponentStack— never swallow silently.
Performance
React.memoonly when profiling shows unnecessary re-renders.- Code splitting:
React.lazy+Suspensefor route-level splitting:import { lazy, Suspense } from 'react'; const TaskPage = lazy(() => import('./features/task/TaskPage')); const ProfilePage = lazy(() => import('./features/profile/ProfilePage')); function AppRoutes() { return ( <Suspense fallback={<PageSkeleton />}> <Routes> <Route path="/tasks" element={<TaskPage />} /> <Route path="/profile" element={<ProfilePage />} /> </Routes> </Suspense> ); } - Virtual scrolling for long lists (TanStack Virtual).
- Image optimization — use
loading="lazy"andsrcSetfor responsive images. - Avoid inline object/array literals in props if causing re-render issues — hoist or
useMemo.
Anti-Patterns
- ❌
useEffectfor data fetching — use TanStack Query, SWR, or loaders - ❌ Prop drilling through 3+ levels — use Context or state manager
- ❌
key={index}on dynamic lists — use stable, unique identifiers - ❌
useMemo/useCallbackon everything — premature optimization - ❌ State for derived data — compute during render:
// ❌ Unnecessary state const [filteredTasks, setFilteredTasks] = useState<Task[]>([]); useEffect(() => { setFilteredTasks(tasks.filter(t => t.status === filter)); }, [tasks, filter]); // ✅ Computed during render — no extra state const filteredTasks = tasks.filter(t => t.status === filter); - ❌ Direct DOM manipulation — use refs and React's render cycle
- ❌
useFormState— replaced byuseActionStatein React 19 - ❌ Global state for server data — use TanStack Query/SWR instead
Testing
For universal testing principles, see
.agents/rules/testing-strategy.md. Below: React-specific patterns only.
React Testing Library + Vitest/Jest. Test behavior, not implementation.
Component rendering and interaction:
import { render, screen, fireEvent } from '@testing-library/react'; test('displays task title', () => { render(<TaskCard task={mockTask} />); expect(screen.getByText('Deploy fix')).toBeInTheDocument(); }); test('calls onComplete when button clicked', async () => { const render(<TaskCard task={mockTask} />); await fireEvent.click(screen.getByRole('button', { name: /complete/i })); expect(onComplete).toHaveBeenCalledWith(mockTask.id); });Provider wrapper for tests — wrap components that depend on providers:
function createTestWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return ({ children }: { children: React.ReactNode }) => ( <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ); } render(<TaskList />, { wrapper: createTestWrapper() });Testing custom hooks with
renderHook:import { renderHook, waitFor } from '@testing-library/react'; test('useTask returns task data', async () => { const { result } = renderHook(() => useTask('1'), { wrapper: createTestWrapper(), }); await waitFor(() => expect(result.current.task).toBeDefined()); expect(result.current.task?.title).toBe('Deploy fix'); });MSW for API mocking — intercept at the network level:
import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; const server = setupServer( http.get('/api/tasks', () => HttpResponse.json([{ id: '1', title: 'Deploy fix', status: 'todo' }]) ), ); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());
Formatting and Static Analysis
| Tool | Purpose | Command |
|---|---|---|
| Prettier | Formatting | npx prettier --write . |
| ESLint + eslint-plugin-react-hooks | Linting | npx eslint . |
| TypeScript | Type checking | npx tsc --noEmit |
Related
- Code Idioms and Conventions @.agents/rules/code-idioms-and-conventions.md
- TypeScript Idioms @.agents/skills/typescript-idioms/SKILL.md
- React Project Structure @.agents/skills/react-idioms/references/project-structure.md
- Frontend Layout (framework-neutral, shared with Vue) @.agents/skills/frontend-design/references/frontend-layout.md
- Frontend Design @.agents/skills/frontend-design/SKILL.md
- Next.js Idioms (for Next.js App Router projects) @.agents/skills/nextjs-idioms/SKILL.md
- Security Principles @.agents/rules/security-principles.md
- Accessibility Principles @.agents/rules/accessibility-principles.md
- Testing Strategy @.agents/rules/testing-strategy.md
- Error Handling Principles @.agents/rules/error-handling-principles.md
- Logging and Observability @.agents/rules/logging-and-observability-mandate.md
- Architectural Patterns @.agents/rules/architectural-pattern.md