# Loom React

> Modern client-side React development patterns.

- Skill: `cosmix/loom-react` (Agent Skill)
- Install (CLI): `npx skillmds@latest add cosmix/loom-react`
- Raw SKILL.md: https://api.skillmd.com/api/skills/cosmix/loom-react/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: cosmix (https://skillmd.com/u/cosmix)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/cosmix/loom-react

---


# 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:

```typescript
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

```typescript
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

```typescript
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

```typescript
// 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.

```typescript
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

```typescript
// 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.

```typescript
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

```bash
# 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.

```json
{
  "$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:

```json
{
  "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"
  }
}
```

- `oxfmt` is Prettier-compatible: `.oxfmtrc.json`, honors `.gitignore`/`.prettierignore`, default `printWidth` is 100 where Prettier's is 80 (`oxfmt --migrate=prettier` carries an existing Prettier config over). `oxfmt` writes in place; `oxfmt --check` gates CI.
- `oxlint --fix` applies safe fixes only; `--fix-suggestions`/`--fix-dangerously` may change behavior — review the diff.
- Leave the `react-perf` plugin off while the React Compiler is on: its rules flag the inline handlers and object props the compiler memoizes for you, and under `--deny-warnings` they fail the build.
- Type-aware rules (`typescript/no-floating-promises` on event handlers and loaders, ...): `bun add -D oxlint-tsgolint` and `"options": { "typeAware": true }`. They run on the native TypeScript compiler, so the tsconfig must avoid `baseUrl`.
- Migrating: `bunx -p @oxlint/migrate oxlint-migrate` converts an ESLint flat config to `.oxlintrc.json`; keep ESLint only for a rule oxlint lacks, with `eslint-plugin-oxlint` disabling the overlap and `oxlint && eslint` as the script.

### Vite Configuration

```typescript
// 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.

```json
{
  "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

```typescript
// 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.

```typescript
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.

```typescript
// 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 into `useState`).
- `useNavigation()` (from the router) → global `state === 'loading'` during transitions; drives loading bars.
- `useParams()` → typed route params; `useLoaderData()`/typed `loaderData` prop → loader result.

### Protected Routes Pattern

```typescript
// 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

```typescript
// 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 onClick={increment}>Increment</button>
      <button onClick={() => 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.

```typescript
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

```typescript
// 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}
        onChange={(e) => updateTodo({ completed: e.target.checked })}
      />
      <span>{todo.title}</span>
    </div>
  )
}
```

### Persistent Storage with atomWithStorage

```typescript
// 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.

```typescript
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

```typescript
// 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.

```typescript
// 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.

```typescript
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' : ''} onClick={() => 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.

```typescript
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 onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" value={email} onChange={(e) => 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

```typescript
// 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]} onChange={handleChange}.
```

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

```text
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`:

```typescript
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
// <Suspense fallback={<Spinner />}><DashboardPage /></Suspense>
```

### Error Boundaries

```typescript
// 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 onClick={this.reset}>Try again</button>
        </div>
      )
    }

    return this.props.children
  }
}

// Usage
function App() {
  return (
    <ErrorBoundary
      fallback={(error, reset) => (
        <div>
          <h1>Error: {error.message}</h1>
          <button onClick={reset}>Retry</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`.

```typescript
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 onKey = (e: 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" onClick={onClose} role="presentation">
      <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="modal-title"
        tabIndex={-1} onClick={(e) => 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).

```css
.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.

```typescript
<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. Use `setItems([...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 `useAtom` at the leaf.
- **Fetch-in-effect for render data:** prefer a route loader or async atom + `<Suspense>` over `useState`/`useEffect` fetch 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

```typescript
// 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)
