React SPA Development
Overview
Client-side React 19+ SPAs. Stack: React Router v7 (routing/loaders), Jotai (atomic global state), Vite (build — Rolldown bundler, Oxc transforms and minifier, Lightning CSS), oxlint + oxfmt (lint/format — the Oxc replacements for ESLint and Prettier), Bun (package manager/runtime). NOT for SSR frameworks (Next.js/Remix) — those are out of scope.
The single densest section is Expert Practices at the end — read it first if you know React basics. The middle sections are reference implementations.
React 19 Features
Actions and useActionState
React 19 introduces Actions for handling async state transitions:
import { useActionState } from 'react'
interface FormState {
message: string
error?: string
}
async function updateProfile(previousState: FormState, formData: FormData) {
const name = formData.get('name') as string
try {
await fetch('/api/profile', {
method: 'POST',
body: JSON.stringify({ name }),
})
return { message: 'Profile updated successfully' }
} catch (error) {
return { message: '', error: 'Update failed' }
}
}
export function ProfileForm() {
const [state, formAction, isPending] = useActionState(updateProfile, { message: '' })
return (
<form action={formAction}>
<input type="text" name="name" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Updating...' : 'Update Profile'}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.message && <p className="success">{state.message}</p>}
</form>
)
}
useOptimistic for Instant UI Updates
import { useOptimistic, useState } from 'react'
interface Todo {
id: string
title: string
completed: boolean
}
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
)
async function addTodo(formData: FormData) {
const title = formData.get('title') as string
const tempTodo = { id: crypto.randomUUID(), title, completed: false }
addOptimisticTodo(tempTodo)
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ title }),
})
}
return (
<div>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<form action={addTodo}>
<input type="text" name="title" />
<button type="submit">Add Todo</button>
</form>
</div>
)
}
use() for Reading Promises and Context
import { use, Suspense } from 'react'
import { useLoaderData } from 'react-router'
interface User {
id: string
name: string
}
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
// use() unwraps the promise. It cannot be wrapped in try/catch —
// a rejected promise surfaces at the nearest Error Boundary.
const user = use(userPromise)
return <div>{user.name}</div>
}
// CRITICAL: the Promise must be created OUTSIDE the render cycle. Promises
// created in client components are recreated on every render, so passing an
// inline fetchUser(userId) to use() re-suspends and re-fetches forever.
// In this React Router v7 SPA stack a route loader is the idiomatic stable
// source (one promise per navigation).
export function UserContainer() {
const userPromise = useLoaderData() as Promise<User>
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}
Document Metadata
React 19 hoists <title>/<meta>/<link> rendered anywhere in the tree into <head> — no helper library needed. Just render them inside the component (e.g. a route page).
Server/Client Components: N/A here — a pure SPA has no server, so every component is a client component with full access to browser APIs, hooks, and event handlers. 'use client'/RSC only matter under Next.js/Remix (out of scope).
UI Component Patterns
Design System Foundation
// src/components/ui/Button.tsx
import { ComponentPropsWithoutRef } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-white hover:bg-primary/90',
secondary: 'bg-secondary text-white hover:bg-secondary/90',
outline: 'border border-gray-300 bg-transparent hover:bg-gray-100',
ghost: 'hover:bg-gray-100',
danger: 'bg-red-600 text-white hover:bg-red-700',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4',
lg: 'h-12 px-6 text-lg',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
export interface ButtonProps
extends ComponentPropsWithoutRef<'button'>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean
ref?: React.Ref<HTMLButtonElement>
}
// React 19: ref is a plain prop — no forwardRef wrapper, no displayName needed.
export function Button({
ref,
className,
variant,
size,
isLoading,
children,
...props
}: ButtonProps) {
return (
<button
ref={ref}
className={buttonVariants({ variant, size, className })}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" />
Loading...
</>
) : (
children
)}
</button>
)
}
Composition (Card slots)
Ship a family of thin primitives that forward ref (a plain prop in React 19) and className. Callers compose them; no prop explosion.
type DivProps = ComponentPropsWithoutRef<'div'> & { ref?: React.Ref<HTMLDivElement> }
export function Card({ ref, className, ...props }: DivProps) {
return <div ref={ref} className={`rounded-lg border bg-white shadow-sm ${className}`} {...props} />
}
export function CardHeader({ ref, className, ...props }: DivProps) {
return <div ref={ref} className={`p-6 ${className}`} {...props} />
}
export function CardContent({ ref, className, ...props }: DivProps) {
return <div ref={ref} className={`p-6 pt-0 ${className}`} {...props} />
}
// <Card><CardHeader>…</CardHeader><CardContent>…</CardContent></Card>
Polymorphic Components
// src/components/ui/Text.tsx
import { ElementType, ComponentPropsWithoutRef } from 'react'
type TextProps<E extends ElementType> = {
as?: E
variant?: 'h1' | 'h2' | 'h3' | 'body' | 'small'
} & ComponentPropsWithoutRef<E>
export function Text<E extends ElementType = 'p'>({
as,
variant = 'body',
className,
...props
}: TextProps<E>) {
const Component = as || 'p'
const variantClasses = {
h1: 'text-4xl font-bold',
h2: 'text-3xl font-semibold',
h3: 'text-2xl font-semibold',
body: 'text-base',
small: 'text-sm text-gray-600',
}
return (
<Component
className={`${variantClasses[variant]} ${className || ''}`}
{...props}
/>
)
}
// Usage - flexible element types
<Text variant="h1">Heading</Text>
<Text as="h1" variant="h1">Heading with h1 tag</Text>
<Text as="span" variant="small">Small text in span</Text>
Render Props / Function-as-Children
Generic state-branching component. children: (data: T) => ReactNode. In this stack prefer Suspense + async atoms/loaders for data; render props remain useful for non-Suspense state machines.
export function DataLoader<T>({ data, isLoading, error, children }: {
data: T | null; isLoading: boolean; error: Error | null; children: (data: T) => ReactNode
}) {
if (isLoading) return <div>Loading…</div>
if (error) return <div>Error: {error.message}</div>
if (!data) return null
return <>{children(data)}</>
}
// <DataLoader {...useFetch<User[]>('/api/users')}>{(u) => …}</DataLoader>
Project Setup
Initial Setup with Bun and Vite
# Create new React app with Vite template (Vite runs on Rolldown + Oxc)
bun create vite my-app --template react-ts
cd my-app
# Install dependencies
bun install
# Add React Router and Jotai
bun add react-router jotai
# Add development dependencies: types, oxlint (lint), oxfmt (format)
bun add -D @types/react @types/react-dom oxlint oxfmt
bunx oxlint --init # .oxlintrc.json — then enable the React plugins (next section)
bunx oxfmt --init # .oxfmtrc.json
# If the template scaffolded ESLint, remove it — oxlint replaces it:
# delete eslint.config.js and `bun remove` every eslint*/typescript-eslint devDependency
# Start development server
bun run dev
Lint and Format (oxlint + oxfmt)
oxlint and oxfmt are the Oxc-native replacements for ESLint and Prettier — same job, no plugin graph, no linter/formatter conflict config. Setting plugins in .oxlintrc.json REPLACES the default set (eslint, typescript, unicorn, oxc), so list the defaults you keep next to the React ones. Only the correctness category is on by default: react/rules-of-hooks is pedantic, so it stays off until you name it, and react/exhaustive-deps is correctness but only fires once react is in plugins — list both.
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "jsx-a11y", "import", "vitest"],
"categories": { "correctness": "error", "suspicious": "warn" },
"rules": {
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "error",
"react/jsx-key": "error",
"typescript/no-explicit-any": "error"
},
"env": { "browser": true },
"ignorePatterns": ["dist/**", "coverage/**"]
}
package.json scripts:
{
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "oxlint --deny-warnings",
"lint:fix": "oxlint --fix",
"format": "oxfmt",
"format:check": "oxfmt --check",
"check": "bun run typecheck && bun run lint && bun run format:check && bun run test"
}
}
oxfmtis Prettier-compatible:.oxfmtrc.json, honors.gitignore/.prettierignore, defaultprintWidthis 100 where Prettier's is 80 (oxfmt --migrate=prettiercarries an existing Prettier config over).oxfmtwrites in place;oxfmt --checkgates CI.oxlint --fixapplies safe fixes only;--fix-suggestions/--fix-dangerouslymay change behavior — review the diff.- Leave the
react-perfplugin off while the React Compiler is on: its rules flag the inline handlers and object props the compiler memoizes for you, and under--deny-warningsthey fail the build. - Type-aware rules (
typescript/no-floating-promiseson event handlers and loaders, ...):bun add -D oxlint-tsgolintand"options": { "typeAware": true }. They run on the native TypeScript compiler, so the tsconfig must avoidbaseUrl. - Migrating:
bunx -p @oxlint/migrate oxlint-migrateconverts an ESLint flat config to.oxlintrc.json; keep ESLint only for a rule oxlint lacks, witheslint-plugin-oxlintdisabling the overlap andoxlint && eslintas the script.
Vite Configuration
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
// Fast Refresh is always on and JSX is transformed by Oxc — no Babel in the
// pipeline. Pass options only for real settings: react({ compiler: true })
// turns on the React Compiler (Rust port; `bun add -D oxc-transform-react`).
// Babel-only transforms are a separate plugin (`@rolldown/plugin-babel`),
// not a `babel` option on react().
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@components": path.resolve(__dirname, "./src/components"),
"@hooks": path.resolve(__dirname, "./src/hooks"),
"@store": path.resolve(__dirname, "./src/store"),
"@utils": path.resolve(__dirname, "./src/utils"),
},
},
server: {
port: 3000,
open: true,
},
build: {
sourcemap: true,
// minify defaults to Oxc and CSS minify to Lightning CSS. `rollupOptions`
// is a deprecated alias, and the object form of `manualChunks` is gone —
// group vendor chunks with Rolldown's `codeSplitting`.
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{ name: "react-vendor", test: /node_modules[\\/](react|react-dom)[\\/]/ },
{ name: "router", test: /node_modules[\\/]react-router/ },
{ name: "state", test: /node_modules[\\/]jotai/ },
],
},
},
},
},
});
TypeScript Configuration
No baseUrl: paths resolves relative to this file without it, and oxlint's type-aware pass (tsgo) rejects it.
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@hooks/*": ["./src/hooks/*"],
"@store/*": ["./src/store/*"],
"@utils/*": ["./src/utils/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
React Router v7 Patterns
Router Setup with createBrowserRouter
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createBrowserRouter } from 'react-router'
import './index.css'
// Import route components
import { RootLayout } from './layouts/RootLayout'
import { HomePage } from './pages/HomePage'
import { AboutPage } from './pages/AboutPage'
import { UsersPage } from './pages/users/UsersPage'
import { UserDetailPage } from './pages/users/UserDetailPage'
import { ErrorPage } from './pages/ErrorPage'
import { NotFoundPage } from './pages/NotFoundPage'
// Create router with type-safe route definitions
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
{
index: true,
element: <HomePage />,
},
{
path: 'about',
element: <AboutPage />,
},
{
path: 'users',
children: [
{
index: true,
element: <UsersPage />,
loader: async () => {
// Data loading for users list
const response = await fetch('/api/users')
return response.json()
},
},
{
path: ':userId',
element: <UserDetailPage />,
loader: async ({ params }) => {
// Data loading for specific user
const response = await fetch(`/api/users/${params.userId}`)
if (!response.ok) {
throw new Response('User not found', { status: 404 })
}
return response.json()
},
},
],
},
{
path: '*',
element: <NotFoundPage />,
},
],
},
])
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
)
Root Layout with Outlet
The layout renders shared chrome (<nav>) plus <Outlet /> for the matched child route; useNavigation().state === 'loading' drives a global pending indicator.
export function RootLayout() {
const isNavigating = useNavigation().state === 'loading'
return (
<>
<nav><Link to="/">Home</Link><Link to="/users">Users</Link></nav>
<main>{isNavigating && <div className="loading-bar" />}<Outlet /></main>
</>
)
}
Data Loading with Loaders
React Router v7 framework mode generates a per-route +types/<route>.d.ts via
react-router typegen, exposing a Route namespace (LoaderArgs, ActionArgs,
ComponentProps). Consume loader data through the typed loaderData prop — NOT
useLoaderData() as SomeType, an unsafe cast that hides divergence between the
loader's real return and the component's expectation.
// src/pages/users/UsersPage.tsx
import { Link } from 'react-router'
import type { Route } from './+types/UsersPage'
interface User {
id: string
name: string
email: string
}
export async function loader(): Promise<User[]> {
return (await fetch('/api/users')).json()
}
export default function UsersPage({ loaderData }: Route.ComponentProps) {
return (
<div>
<h1>Users</h1>
<ul>
{loaderData.map((user) => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>
{user.name} ({user.email})
</Link>
</li>
))}
</ul>
</div>
)
}
Typegen setup: add .react-router/ to .gitignore, set tsconfig include to
.react-router/types/**/*, set compilerOptions.rootDirs to
[".", "./.react-router/types"], and run react-router typegen && tsc.
Navigation Hooks
useNavigate()→ imperative nav:navigate('/users/' + id),navigate(-1),navigate(path, { replace: true, state }).useSearchParams()→[params, setSearchParams];params.get('filter'),setSearchParams({ filter })(updates URL, drives derived state — do NOT mirror URL intouseState).useNavigation()(from the router) → globalstate === 'loading'during transitions; drives loading bars.useParams()→ typed route params;useLoaderData()/typedloaderDataprop → loader result.
Protected Routes Pattern
// src/components/ProtectedRoute.tsx
import { Navigate, Outlet } from 'react-router'
import { useAtomValue } from 'jotai'
import { userAtom } from '@store/auth'
export function ProtectedRoute() {
const user = useAtomValue(userAtom)
if (!user) {
return <Navigate to="/login" replace />
}
return <Outlet />
}
// Usage in router configuration
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{
path: 'dashboard',
element: <ProtectedRoute />,
children: [
{
index: true,
element: <DashboardPage />,
},
{
path: 'settings',
element: <SettingsPage />,
},
],
},
],
},
])
Jotai State Management
⚠️ Define every atom at module scope, never inside a component. An atom is an identity/key, not a value — the store maps atom identity → state. An atom created in render is a brand-new key each render, so state never persists and subscribers thrash. For per-item/per-id atoms use atomFamily (memoizes by param at module scope), not useMemo(() => atom(...)).
Hooks: useAtom (read+write), useAtomValue (read), useSetAtom (write-only — subscriber does NOT re-render on value change; use for actions).
Basic Atoms
// src/store/counter.ts
import { atom } from 'jotai'
// Primitive atom
export const countAtom = atom(0)
// Read-only derived atom
export const doubledCountAtom = atom((get) => get(countAtom) * 2)
// Read-write derived atom
export const incrementAtom = atom(
(get) => get(countAtom),
(get, set) => set(countAtom, get(countAtom) + 1)
)
export const decrementAtom = atom(
null,
(get, set) => set(countAtom, get(countAtom) - 1)
)
// Usage in component
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
export function Counter() {
const [count, setCount] = useAtom(countAtom)
const doubled = useAtomValue(doubledCountAtom)
const increment = useSetAtom(incrementAtom)
return (
<div>
<p>Count: {count}</p>
<p>Doubled: {doubled}</p>
<button
<button => setCount((c) => c - 1)}>Decrement</button>
</div>
)
}
Async Atoms
An atom whose read fn returns a Promise integrates with Suspense automatically: useAtomValue unwraps it, and the nearest <Suspense> shows the fallback while pending, the nearest ErrorBoundary catches rejection. Add a "refresh trigger" atom as a dependency to force refetch.
export const usersAtom = atom(async () => {
const res = await fetch('/api/users')
if (!res.ok) throw new Error('Failed to fetch users') // → ErrorBoundary
return res.json() as Promise<User[]>
})
export const refreshUsersAtom = atom(0) // set() to bump; the atom below re-reads
export const refreshableUsersAtom = atom(async (get) => {
get(refreshUsersAtom)
return (await fetch('/api/users')).json() as Promise<User[]>
})
// Consumer: const users = useAtomValue(usersAtom) inside a <Suspense fallback={…}>.
Atom Families
// src/store/todos.ts
import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'
interface Todo {
id: string
title: string
completed: boolean
}
// Base todos atom
export const todosAtom = atom<Todo[]>([])
// Atom family for individual todos
export const todoAtomFamily = atomFamily((id: string) =>
atom(
(get) => get(todosAtom).find((todo) => todo.id === id),
(get, set, update: Partial<Todo>) => {
const todos = get(todosAtom)
const index = todos.findIndex((todo) => todo.id === id)
if (index !== -1) {
const newTodos = [...todos]
newTodos[index] = { ...newTodos[index]!, ...update }
set(todosAtom, newTodos)
}
}
)
)
// Usage
function TodoItem({ id }: { id: string }) {
const [todo, updateTodo] = useAtom(todoAtomFamily(id))
if (!todo) return null
return (
<div>
<input
type="checkbox"
checked={todo.completed}
=> updateTodo({ completed: e.target.checked })}
/>
<span>{todo.title}</span>
</div>
)
}
Persistent Storage with atomWithStorage
// src/store/auth.ts
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
interface User {
id: string;
name: string;
email: string;
token: string;
}
// Persists to localStorage automatically
export const userAtom = atomWithStorage<User | null>("user", null);
export const isAuthenticatedAtom = atom((get) => {
const user = get(userAtom);
return user !== null;
});
// Login action
export const loginAtom = atom(
null,
async (get, set, credentials: { email: string; password: string }) => {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error("Login failed");
}
const user = await response.json();
set(userAtom, user);
return user;
},
);
// Logout action
export const logoutAtom = atom(null, (get, set) => {
set(userAtom, null);
});
Composition: base + derived + write-only actions
The idiom: one persisted/base atom, read-only derived atoms (atom((get) => …)) for computed views, and write-only action atoms (atom(null, (get, set, arg) => …)) that encapsulate mutations. Components read derived atoms and call actions — never duplicate derived data into separate state.
export const cartItemsAtom = atomWithStorage<CartItem[]>('cart', [])
export const cartTotalAtom = atom((get) =>
get(cartItemsAtom).reduce((s, i) => s + i.price * i.quantity, 0))
export const addToCartAtom = atom(null, (get, set, item: CartItem) => {
const items = get(cartItemsAtom)
const i = items.findIndex((x) => x.productId === item.productId)
set(cartItemsAtom, i === -1
? [...items, item]
: items.map((x, idx) => idx === i ? { ...x, quantity: x.quantity + item.quantity } : x))
})
Component Patterns
Custom Hooks
// src/hooks/useDebounce.ts
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}
// const debounced = useDebounce(search, 300) — drive a derived query, not a setState-in-effect
For localStorage-backed state prefer Jotai atomWithStorage over a hand-rolled useLocalStorage — it handles serialization, cross-tab sync, and shared identity. Roll your own only for truly local, non-shared values.
// src/hooks/useFetch.ts — abort on unmount / url change to avoid setState-after-unmount
import { useState, useEffect } from "react";
interface UseFetchResult<T> {
data: T | null;
error: Error | null;
isLoading: boolean;
refetch: () => void;
}
export function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [refetchIndex, setRefetchIndex] = useState(0);
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
setData(json);
} catch (err) {
if (err instanceof Error && err.name !== "AbortError") {
setError(err);
}
} finally {
setIsLoading(false);
}
};
fetchData();
return () => {
controller.abort();
};
}, [url, refetchIndex]);
const refetch = () => setRefetchIndex((i) => i + 1);
return { data, error, isLoading, refetch };
}
Compound Components Pattern
Share implicit state via Context between a parent and its named sub-components; hang children off the parent (Tabs.Tab). A useTabs() guard hook throws if used outside the provider — fail loud, not silently.
const TabsContext = createContext<{ active: string; setActive: (id: string) => void } | undefined>(undefined)
const useTabs = () => {
const c = useContext(TabsContext)
if (!c) throw new Error('Tabs.* must be used within <Tabs>')
return c
}
export function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
const [active, setActive] = useState(defaultTab)
return <TabsContext value={{ active, setActive }}>{children}</TabsContext> // React 19: context is its own provider
}
function Tab({ id, children }: { id: string; children: ReactNode }) {
const { active, setActive } = useTabs()
return <button className={active === id ? 'active' : ''} => setActive(id)}>{children}</button>
}
function TabPanel({ id, children }: { id: string; children: ReactNode }) {
return useTabs().active === id ? <div>{children}</div> : null
}
Tabs.Tab = Tab
Tabs.TabPanel = TabPanel
// <Tabs defaultTab="a"><Tabs.Tab id="a">A</Tabs.Tab><Tabs.TabPanel id="a">…</Tabs.TabPanel></Tabs>
Form Handling
Controlled Forms with Validation
Controlled inputs (value + onChange), validate on submit, and wire errors accessibly: <label htmlFor>, aria-invalid, aria-describedby pointing at a role="alert" message. Disable the submit while pending. Extract this into useForm (below) once you have more than one form.
export function LoginForm() {
const login = useSetAtom(loginAtom)
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [errors, setErrors] = useState<{ email?: string }>({})
const [pending, setPending] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return setErrors({ email: 'Invalid email' })
setPending(true)
try { await login({ email }); navigate('/dashboard') }
catch { setErrors({ email: 'Invalid credentials' }) }
finally { setPending(false) }
}
return (
<form
<label htmlFor="email">Email</label>
<input id="email" type="email" value={email} => setEmail(e.target.value)}
aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : undefined} />
{errors.email && <span id="email-error" role="alert">{errors.email}</span>}
<button type="submit" disabled={pending}>{pending ? 'Logging in…' : 'Log In'}</button>
</form>
)
}
Form with Custom Hook
// src/hooks/useForm.ts
import { useState, ChangeEvent, FormEvent } from 'react'
interface UseFormOptions<T> {
initialValues: T
validate?: (values: T) => Partial<Record<keyof T, string>>
onSubmit: (values: T) => void | Promise<void>
}
export function useForm<T extends Record<string, any>>({
initialValues,
validate,
onSubmit,
}: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setValues((prev) => ({ ...prev, [name]: value }))
// Clear error for this field
if (errors[name as keyof T]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[name as keyof T]
return newErrors
})
}
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (validate) {
const validationErrors = validate(values)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
}
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
}
const reset = () => {
setValues(initialValues)
setErrors({})
setIsSubmitting(false)
}
return {
values,
errors,
isSubmitting,
handleChange,
handleSubmit,
reset,
setValues,
setErrors,
}
}
// Usage: const { values, errors, isSubmitting, handleChange, handleSubmit } =
// useForm({ initialValues, validate, onSubmit })
// Inputs use name={key} value={values[key]}
For anything beyond trivial forms, consider React 19 Actions (useActionState, <form action={fn}>) or a schema validator (Zod) instead of hand-rolled validators.
Best Practices
Component Organization
src/
├── components/ # Reusable UI components
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx
│ │ └── Button.module.css
│ └── Input/
├── pages/ # Route components
│ ├── HomePage.tsx
│ └── users/
│ ├── UsersPage.tsx
│ └── UserDetailPage.tsx
├── layouts/ # Layout components
│ └── RootLayout.tsx
├── hooks/ # Custom hooks
│ ├── useDebounce.ts
│ └── useForm.ts
├── store/ # Jotai atoms
│ ├── auth.ts
│ ├── cart.ts
│ └── users.ts
├── utils/ # Utility functions
│ └── api.ts
├── types/ # TypeScript types
│ └── index.ts
└── main.tsx # Entry point
Performance Optimization
Memoization (memo/useMemo/useCallback) is a PERF tool, not a correctness tool, and the React Compiler now automates it — see Expert Practices for the mechanism and traps. In new code, prefer architectural fixes (move state down, split components, stable keys) over scattering memoization.
Route-level code splitting is the highest-leverage manual win — always split routes with lazy + Suspense:
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
// <Suspense fallback={<Spinner />}><DashboardPage /></Suspense>
Error Boundaries
// src/components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: (error: Error, reset: () => void) => ReactNode
}
interface State {
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { error: null }
}
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo)
}
reset = () => {
this.setState({ error: null })
}
render() {
if (this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.reset)
}
return (
<div role="alert">
<h2>Something went wrong</h2>
<pre>{this.state.error.message}</pre>
<button again</button>
</div>
)
}
return this.props.children
}
}
// Usage
function App() {
return (
<ErrorBoundary
fallback={(error, reset) => (
<div>
<h1>Error: {error.message}</h1>
<button
</div>
)}
>
<YourApp />
</ErrorBoundary>
)
}
Accessibility (a11y)
Core rules: prefer semantic elements (<button>, <nav>, <main>) over <div role>; every input needs an associated <label htmlFor> (or useId); errors go in role="alert" linked via aria-describedby; interactive custom widgets need full keyboard support + aria-expanded/aria-haspopup/aria-controls; announce async results via a live region.
Modal Dialog with Focus Management
role="dialog" + aria-modal="true" + aria-labelledby. On open: save document.activeElement, focus the dialog, trap Tab, close on Escape, lock body scroll; on cleanup restore focus. Render via createPortal to document.body.
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const prevFocus = useRef<HTMLElement | null>(null)
useEffect(() => {
if (!isOpen) return
prevFocus.current = document.activeElement as HTMLElement
dialogRef.current?.focus()
const nodes = dialogRef.current?.querySelectorAll<HTMLElement>(
'button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])')
const first = nodes?.[0], last = nodes?.[nodes.length - 1]
const KeyboardEvent) => {
if (e.key === 'Escape') return onClose()
if (e.key !== 'Tab') return
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus() }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus() }
}
document.addEventListener('keydown', onKey)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = ''
prevFocus.current?.focus() // restore focus — critical for keyboard users
}
}, [isOpen, onClose])
if (!isOpen) return null
return createPortal(
<div className="modal-overlay" role="presentation">
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="modal-title"
tabIndex={-1} => e.stopPropagation()}>
<h2 id="modal-title">{title}</h2>
{children}
</div>
</div>, document.body)
}
⚠️ The native <dialog> element with showModal() gives focus trap + Escape + scroll lock for free — prefer it over a hand-rolled trap unless you need custom overlay behavior.
Keyboard Widgets (dropdown/menu essentials)
Trigger: aria-haspopup, aria-expanded={isOpen}, aria-controls={menuId}; open on Enter/Space/ArrowDown. Menu: role="menu", items role="menuitem", focus the first item on open. Keys: Escape closes and returns focus to the trigger; ArrowUp/Down move between items. Same skeleton (roving focus + aria-*) applies to comboboxes, listboxes, tabs.
Skip Link + Visually-Hidden
Skip link: first focusable element, off-screen until focused, targets <main id="main-content" tabIndex={-1}>. Reuse the .visually-hidden class for screen-reader-only text and live regions (do NOT use display:none — that hides from AT too).
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0; }
.skip-link { position: absolute; left: -10000px; }
.skip-link:focus { left: 0; width: auto; height: auto; } /* CSS :focus, not JS onFocus */
Live Region for Announcements
A single app-level aria-live region announces async results (saves, errors, route changes). Trap: setting the same text twice is NOT re-announced — clear to '' then set on the next tick to force it. Use polite for status, assertive for errors.
<div role="status" aria-live={priority} aria-atomic="true" className="visually-hidden">{message}</div>
// announce(): setMessage(''); setTimeout(() => setMessage(text), 100)
Anti-Patterns
Forbidden in this stack
Next.js / Remix (SSR — out of scope), next/* imports · create-react-app (deprecated) · webpack configs (use Vite) · Redux/RTK (use Jotai; exception: existing Redux codebases) · Context for hot global state (use Jotai; Context is for ambient subtree values like theme) · class components · default exports (prefer named) · ESLint/Prettier/Biome (use oxlint + oxfmt) · esbuild or Babel options in vite.config.ts (Vite runs on Oxc: esbuild → oxc, build.rollupOptions → build.rolldownOptions).
Common Mistakes
- Mutation:
items.push(x); setItems(items)— React compares by reference, no re-render. UsesetItems([...items, x])/setItems(prev => [...prev, x]). - Derived state in Effect:
useEffect(() => setFiltered(items.filter(f)), [items,f])— compute during render instead:const filtered = items.filter(f). - Missing deps: every value read inside an Effect belongs in its dep array (enable oxlint's
react/exhaustive-deps); don't disable the lint — fix the design. - Prop drilling shared state: lift to a Jotai atom, read via
useAtomat the leaf. - Fetch-in-effect for render data: prefer a route loader or async atom +
<Suspense>overuseState/useEffectfetch triads. If you must fetch in an Effect, use the ignore-flag pattern (see Gotchas) to avoid races and setState-after-unmount.
Quick Pattern Swaps
// BAD: Calling Hooks conditionally
function SearchPanel({ enabled }: { enabled: boolean }) {
if (enabled) {
useEffect(() => {
subscribe()
}, [])
}
return null
}
// GOOD: Call Hooks at the top level and branch inside
function SearchPanel({ enabled }: { enabled: boolean }) {
useEffect(() => {
if (!enabled) return
const unsubscribe = subscribe()
return unsubscribe
}, [enabled])
return null
}
// BAD: Using unstable keys
items.map((item, index) => <Row key={index} item={item} />)
items.map((item) => <Row key={Math.random()} item={item} />)
// GOOD: Use stable IDs from the data
items.map((item) => <Row key={item.id} item={item} />)
// BAD: Resetting state in a
…(truncated)