| name | loom-react |
| description | Modern React development patterns. Use for building React 19+ applications with components, hooks, Jotai state, React Router v7, server components, accessibility, performance optimization, and testing. Covers client-side routing, composition, and async data loading. |
| triggers | ["react","jsx","tsx","component","hook","useState","useEffect","useContext","useReducer","useMemo","useCallback","useRef","props","state","render","virtual DOM","reconciliation","single page application","spa","react-router","jotai","vite","bun","Next.js","Remix","client-side routing","server components","accessibility","a11y","ARIA","performance","code splitting","lazy loading","Suspense","error boundaries","form validation","UI components","design system","composition patterns"] |
React SPA Development
Overview
Client-side React 19+ SPAs. Stack: React Router v7 (routing/loaders), Jotai (atomic global state), Vite (build), 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) => (
{todo.title}
))}
Add Todo
)
}
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> }) {
const user = use(userPromise)
return <div>{user.name}</div>
}
export function UserContainer() {
const userPromise = useLoaderData() as Promise<User>
return (
<Suspense fallback={<div>Loading user...}>
)
}
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
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> {
?:
?: .<>
}
() {
(
) : (
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 ${}`} {} />
}
Polymorphic Components
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}
/>
)
}
< variant=></>
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)}</>
}
Project Setup
Initial Setup with Bun and Vite
bun create vite my-app --template react-ts
cd my-app
bun install
bun add react-router jotai
bun add -D @types/react @types/react-dom
bun run dev
Vite Configuration
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
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,
rollupOptions: {
output: {
manualChunks: {
"react-vendor": ["react", ],
: [],
: [],
},
},
},
},
});
TypeScript Configuration
{
"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"
React Router v7 Patterns
Router Setup with createBrowserRouter
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createBrowserRouter } from 'react-router'
import './index.css'
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'
const router = createBrowserRouter([
{
path: '/',
element: < />,
: ,
: [
{
: ,
: ,
},
{
: ,
: ,
},
{
: ,
: [
{
: ,
: ,
: () => {
response = ()
response.()
},
},
{
: ,
: ,
: ({ params }) => {
response = ()
(!response.) {
(, { : })
}
response.()
},
},
],
},
{
: ,
: ,
},
],
},
])
(.()!).(
)
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.
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})
))}
)
}
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
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 />
}
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{
path: 'dashboard',
element: <ProtectedRoute />,
children: [
{
index: true,
element: <DashboardPage />,
},
{
path: 'settings',
: ,
},
],
},
],
},
])
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
import { atom } from 'jotai'
export const countAtom = atom(0)
export const doubledCountAtom = atom((get) => get(countAtom) * 2)
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)
)
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
export function Counter() {
const [count, setCount] = useAtom(countAtom)
const doubled = useAtomValue(doubledCountAtom)
const increment = (incrementAtom)
(
)
}
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')
return res.json() as Promise<User[]>
})
export const refreshUsersAtom = atom(0)
export const refreshableUsersAtom = atom(async (get) => {
get(refreshUsersAtom)
return (await fetch('/api/users')).json() as Promise<User[]>
})
Atom Families
import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'
interface Todo {
id: string
title: string
completed: boolean
}
export const todosAtom = atom<Todo[]>([])
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)
}
}
)
)
() {
[todo, updateTodo] = ((id))
(!todo)
(
)
}
Persistent Storage with atomWithStorage
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
interface User {
id: string;
name: string;
email: string;
token: string;
}
export const userAtom = atomWithStorage<User | null>("user", null);
export const isAuthenticatedAtom = atom((get) => {
const user = get(userAtom);
return user !== null;
});
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": },
: .(credentials),
});
(!response.) {
();
}
user = response.();
(userAtom, user);
user;
},
);
logoutAtom = (, {
(userAtom, );
});
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
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
}
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.
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 (url, { : controller. });
(!response.) {
();
}
json = response.();
(json);
} (err) {
(err && err. !== ) {
(err);
}
} {
();
}
};
();
{
controller.();
};
}, [url, refetchIndex]);
= () => ( i + );
{ 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>
}
function Tab({ id, children }: { id: string; children: ReactNode }) {
const { active, setActive } = useTabs()
return <button className= === ? '' ''} = => setActive(id)}>{children}
}
() {
(). === id ? :
}
. =
. =
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 onSubmit={handleSubmit}>
Email
setEmail(e.target.value)}
aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : undefined} />
{errors.email && {errors.email}}
{pending ? 'Logging in…' : 'Log In'}
)
}
Form with Custom Hook
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 }))
(errors[name keyof T]) {
( {
newErrors = { ...prev }
newErrors[name keyof T]
newErrors
})
}
}
= () => {
e.()
(validate) {
validationErrors = (values)
(.(validationErrors). > ) {
(validationErrors)
}
}
()
{
(values)
} {
()
}
}
= () => {
(initialValues)
({})
()
}
{
values,
errors,
isSubmitting,
handleChange,
handleSubmit,
reset,
setValues,
setErrors,
}
}
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'))
Error Boundaries
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, errorInfo)
}
reset = {
.({ : })
}
() {
(..) {
(..) {
..(.., .)
}
(
)
}
..
}
}
() {
(
)
}
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 onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') return onClose()
if (e.key !== 'Tab') return
if (e.shiftKey && document.activeElement === first) { e.(); last?.() }
(!e. && . === last) { e.(); first?.() }
}
.(, onKey)
... =
{
.(, onKey)
... =
prevFocus.?.()
}
}, [isOpen, onClose])
(!isOpen)
(
, .)
}
⚠️ 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; }
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>
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).
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
eslint-plugin-react-hooks); 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
function SearchPanel({ enabled }: { enabled: boolean }) {
if (enabled) {
useEffect(() => {
subscribe()
}, [])
}
return null
}
function SearchPanel({ enabled }: { enabled: boolean }) {
useEffect(() => {
if (!enabled) return
const unsubscribe = subscribe()
return unsubscribe
}, [enabled])
return null
}
items.map((item, index) => <Row key={index} item={item} />)
items.map((item) => <Row key={Math.random()} item={item} />)
items.map((item) => <Row = = />)
() {
[draft, setDraft] = ()
( {
()
}, [thread.])
}
() {
}
Core Hook Pattern Swaps
Defaults for refs vs state vs effects vs memo vs shared state. Most rationale is in Expert Practices; quick rules:
- UI value that affects render →
useState, not a ref. Refs hold non-render values (DOM nodes, timers, latest-value stashes).
- Value derivable from props/state → compute during render, never
useEffect + setState.
useCallback/useMemo only to stabilize props for a memoized child/Effect or for genuinely expensive compute — not by default (the Compiler handles the rest).
- Related fields with coupled transitions →
useReducer; hot global state → Jotai; ambient subtree value → Context.
Latest value inside an Effect callback (a subscription handler that must see fresh theme without re-subscribing on every theme change) → useEffectEvent, not a manually-synced ref:
function ChatRoom({ roomId, theme }: { roomId: string; theme: Theme }) {
const onConnected = useEffectEvent(() => {
showToast("Connected", theme)
})
useEffect(() => {
const connection = createConnection(roomId)
connection.on("connected", onConnected)
connection.connect()
return () => connection.disconnect()
}, [roomId])
}
type FormState = { status: 'idle' | 'saving' | 'error'; data: { name: string }; error: string | null }
=
| { : ; : }
| { : } | { : ; : } | { : }
(): {
(action.) {
: { ...state, : { ...state., : action. } }
: { ...state, : , : }
: { ...state, : , : action. }
: { ...state, : , : }
}
}
( {
(.().)
frame = ( () { (t); frame = (tick) })
(frame)
}, [])
Testing
Vitest + React Testing Library (jsdom). Config: test: { globals: true, environment: 'jsdom', setupFiles: './src/test/setup.ts' } in vite.config.ts; in setup.ts extend expect with @testing-library/jest-dom/matchers and afterEach(cleanup).
Test behavior via accessible roles/text, not implementation. Query with getByRole/getByLabelText (which also enforce a11y); use userEvent over fireEvent for realistic interaction; wrap state updates in act.
it('is disabled while loading', () => {
render(<Button isLoading>Save</Button>)
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled()
})
it('increments', () => {
const { result } = renderHook(() => useAtom(countAtom))
act(() => result.current[1]((c) => c + 1))
expect(result.current[0]).toBe(1)
})
⚠️ Each Jotai test needs isolation: atoms hold module-level identity but their VALUES live in a Provider's store. Wrap renderHook/render in a fresh <Provider> (or createStore()) per test so state does not leak between tests.
Expert Practices: Idioms, Anti-Patterns & Gotchas
High-signal rules an expert applies reflexively, with the mechanism that makes each one true. Group: Idioms (the current way), Anti-Patterns (what corrupts React's machinery), Gotchas (correct-looking code that fails), Performance, Currency (React 19.2 / Compiler).
Idioms
Render <Context> directly as the provider — .Provider is no longer needed. React 19 lets the context object itself be the provider: <ThemeContext value={...}>. Behavior is identical; it is pure syntactic simplification. .Provider is NOT yet formally deprecated in 19.x (the blog says "in future versions we will deprecate <Context.Provider>"), but the direct form is forward-looking and a codemod exists. Use one form consistently — mixing both in a codebase is confusing.
const ThemeContext = createContext<Theme>('light')
function App() {
return (
<ThemeContext value="dark">
<Page />
</ThemeContext>
)
}
Pass ref as a regular prop — forwardRef is the legacy pattern. React 19 stopped stripping ref out of the props bag, so the forwardRef HOC indirection is obsolete and you can drop .displayName (a named function declares itself). Precision: in 19.0–19.2 forwardRef still works WITHOUT a deprecation warning — the blog only says it "will deprecate and remove forwardRef" in a future version. So writing new forwardRef wrappers is legacy (and will break later) but does not currently warn. The react-19 codemod migrates existing usages.
Subscribe to external stores with useSyncExternalStore, never useEffect + useState. The effect approach has a window between subscription start and the first snapshot read where the store can change, producing a tear (components in one render see different versions); cleanup is also manual. useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) reads the snapshot synchronously during render and resubscribes if subscribe changes. Pitfall: getSnapshot must be referentially stable — returning a fresh object literal each call triggers an infinite re-render loop. Return cached or primitive values.
const subscribe = (cb: () => void) => {
window.addEventListener('online', cb)
window.addEventListener('offline', cb)
return () => {
window.removeEventListener('online', cb)
window.removeEventListener('offline', cb)
}
}
const getSnapshot = () => navigator.onLine
export function useOnlineStatus() {
return useSyncExternalStore(subscribe, getSnapshot, () => true)
}
useActionState's action receives (previousState, formData) — state FIRST. Imported from 'react' (not 'react-dom'; useFormState is deprecated), it returns [state, formAction, isPending] and guarantees ordering, so rapid resubmits resolve to the last completed action. Two real traps: (1) writing async (formData) => formData.get('name') silently calls .get on the previous state and returns null for every field — state is the first arg. (2) Error convention: RETURN expected/validation errors (they become the new state); THROW unexpected errors (they reach the nearest Error Boundary). The new state is whatever the action returns — it is NOT auto-reset to the initial value on success.
import { useActionState } from 'react'
async function updateProfile(prev: { error?: string; message: string }, formData: FormData) {
const name = formData.get('name') as string
if (!name) return { error: 'Name is required', message: '' }
await saveProfile(name)
return { message: 'Saved' }
}
const [state, formAction, isPending] = useActionState(updateProfile, { message: '' })
Anti-Patterns
Never create the Promise inside the component when using use(). "Promises created in Client Components are recreated on every render." Calling fetchUser(userId) inline and passing it to use() mints a new Promise identity each render — each new identity re-suspends, re-renders, and re-fetches in a loop. Create the Promise outside the render cycle: a React Router loader (stable per navigation, the SPA idiom here), a Jotai async atom (caches per key), or a useState lazy initializer. use() cannot be wrapped in try/catch — handle rejection with an Error Boundary.
Never call a component as a plain function — invoke only via JSX. Component() instead of <Component /> bypasses React's fiber machinery. React keeps an ordered hook registry per fiber; a direct call binds the called component's hooks to the caller's fiber, so they appear to work but corrupt on the next conditional render. Direct calls also break context lookups, StrictMode double-invocation, DevTools, and error boundaries — all of which require React to control invocation. The Rules of React forbid it.
Memoization silently fails when ANY prop is an unstable reference — including children. React.memo skips re-render only when ALL props are shallowly equal. A single inline style={{...}}, inline array, or inline arrow crosses the boundary every render, paying the comparison cost with no benefit. children is especially treacherous: <Memo><p>Hi</p></Memo> always busts memo because <p>Hi</p> is a new element object each render. The optimization is also invisible to callers — a later items ?? [] silently breaks it. memo is reliable mainly for components with no props or only primitive props. Prefer architectural fixes (move state down, composition, split components) over pervasive memoization.
<MemoizedList items={items} style={{ margin: 0 }} />
const stableChildren = <p>Static content</p>
<MemoizedBox>{stableChildren}</MemoizedBox>
Gotchas
State lives at position-in-tree + type — never define components inside render; use key to reset. React's reconciler identifies a component by its tree position combined with its type (the function reference). (1) Same type at the same position preserves state across different props — isFancy ? <Counter isFancy /> : <Counter /> keeps the counter's state, leaking it to the wrong context; force a fresh instance with key. (2) Defining a child component INSIDE a parent's render creates a new function reference every render, so React sees a different type and unmounts/remounts the child — the classic "input resets while typing" bug. Always declare components at module top level. key is also the correct way to reset subtree state on identity change — far better than useEffect(() => setState(initial), [id]), which double-renders with a visible stale frame.
Multiple setState calls in one handler read ONE snapshot — use the updater form. A state variable is a fixed snapshot for the whole handler, so setCount(count + 1) three times increments by 1, not 3. React 18+ batches async callbacks, timeouts, and Promises too, widening the trap. Use setCount(c => c + 1) when an update depends on the prior value — updaters are queued and each receives the previous result. Especially important after await, where a value captured before the await is stale.
setCount(c => c + 1)
setCount(c => c + 1)
setCount(c => c + 1)
Async Effects race on fast mount/unmount — guard with an ignore flag (or AbortController). When an Effect fetches, a rapid unmount→remount (or changing dep) fires a second fetch before the first resolves; responses can arrive out of order, so the STALE one wins and setState fires after unmount. Cleanup cannot cancel an in-flight await, so gate the state write on a per-effect flag. StrictMode's dev double-mount deliberately surfaces this. (In this stack, a route loader or async atom sidesteps it entirely — prefer those for render data.)
useEffect(() => {
let ignore = false
fetchUser(userId).then((u) => { if (!ignore) setUser(u) })
return () => { ignore = true }
}, [userId])
useRef: do not read or write ref.current during render (one exception: lazy init). Concurrent React may render a component multiple times before committing; ref mutations persist across those phantom renders while state does not, so render-phase writes violate purity and produce inconsistent results. Reads/writes belong in effects and event handlers. The one documented exception is idempotent lazy init: read ref.current, and if null, set it. Related waste: useRef(new Expensive()) runs the constructor on EVERY render (the result is discarded after the first) — use the null-guard pattern.
const playerRef = useRef<VideoPlayer | null>(null)
if (playerRef.current === null) {
playerRef.current = new VideoPlayer()
}
React 19 TypeScript: ref callbacks with implicit returns are now rejected — use a block body. React 19 added ref cleanup functions (a ref callback may return () => cleanup(), called on unmount). As a side effect the types reject ANY non-function return, because (node) => (instance = node) is ambiguous against an intentional cleanup. This compiles in JS but is a TS build-breaking change; the fix is mechanical (block-body arrow). The react-19 codemod includes no-implicit-ref-callback-return.
<div ref={(node) => { instance = node }} />
<input ref={(node) => { // ref-with-cleanup: the reason the change exists
if (!node) return
subscribe(node)
return () => unsubscribe(node)
}} />
useOptimistic reverts SILENTLY on error — add explicit feedback and group related state. When the async Action inside startTransition rejects, React reverts the optimistic value but shows NO error UI — the interaction looks successful, then snaps back. Always catch and set explicit error state. The optimistic setter must run inside a Transition/Action context (from a plain handler it warns and reverts immediately). Second trap: separate useOptimistic calls for related values revert independently, briefly showing an inconsistent UI — group related optimistic state into one useOptimistic with a reducer so it reverts atomically.
startTransition(async () => {
addOptimisticItem(newItem)
try {
await saveItem(newItem)
} catch (err) {
setError((err as Error).message)
}
})
const [optimistic, dispatch] = useOptimistic(
{ isFollowing: false, count: 0 },
(state, follow: boolean) => ({ isFollowing: follow, count: state.count + (follow ? 1 : -1) })
)
StrictMode double-invokes renders, initializers, and effects to surface impurity and missing cleanup. In development it runs component bodies twice, calls useState/useReducer initializers twice, and runs each Effect through setup→cleanup→setup. This is intentional and exposes real bugs: impure renders, side-effecting initializers, and Effects missing cleanup (which leak). Production runs effects once, so a missing cleanup "works" until a fast mount/unmount (rapid navigation) triggers the leak — StrictMode forces that cycle in dev. Make every Effect survive setup→cleanup→setup (always return cleanup) and keep initializers idempotent. You cannot opt a subtree out.
React Router errorElement/ErrorBoundary catches loader/action/render errors — NOT event-handler or effect errors. A manual fetch in an onClick or inside useEffect fails silently unless you try/catch it yourself. Second trap: check isRouteErrorResponse(error) FIRST to distinguish intentional HTTP errors (throw data(..., { status: 404 }) from a loader) from real JS Errors — otherwise a deliberate 404 renders as a crash with error.message undefined. Throw in loaders for 404/401; RETURN form-validation/control-flow results, do not throw them.
export function ErrorBoundary() {
const error = useRouteError()
if (isRouteErrorResponse(error)) return <NotFoundPage status={error.status} />
if (error instanceof Error) return <CrashPage message={error.message} />
return <p>Unknown error</p>
}
Jotai atomWithStorage causes a hydration mismatch under SSR/SSG only. It reads localStorage on the client but has no storage on the server, so the first client render diverges from server HTML, tripping React's hydration warning and a visible flash. This is purely an SSR/SSG/pre-rendering concern — in a pure client-side SPA (this skill's target) there is no server HTML to mismatch, so it does not apply. If SSR is ever introduced, wrap storage-dependent UI in a client-only boundary (render after mount) or use useHydrateAtoms. (Avoid relying on a specific delayInit-style option — Jotai's storage API has changed across versions; verify current options first.)
Performance & Currency (React 19.2 / Compiler)
Wrap navigations in startTransition so Suspense does not flash a fallback. Updating state that re-suspends an already-revealed boundary instantly replaces visible content with the fallback — even if new data is 100ms away. Inside a Transition, React keeps rendering the old tree while preparing the new one and does not replace already-revealed content; isPending gives a lightweight indicator (e.g. dimming). Gotcha: in React 19 startTransition accepts async functions, but because JS lacks AsyncContext, state updates AFTER the first await lose the Transition scope and become urgent (can flash) — re-wrap post-await setters in a nested startTransition, or use useActionState which handles ordering.
const [isPending, startTransition] = useTransition()
const navigate = (url: string) => startTransition(() => setPage(url))
Match Suspense boundaries to the UX loading sequence — not one per data-fetching component. "Suspense boundaries should not be more granular than the loading sequence that you want the user to experience." Per-component spinners pop in out of order and feel chaotic; a single global boundary is usually wrong too. Two fetches that should appear together belong under ONE boundary; when one section is much slower, NEST a boundary to reveal the fast part first. This is a UX decision driven by the design.
<Suspense fallback={<BigSpinner />}>
<Avatar userId={id} />
<Suspense fallback={<BioSkeleton />}>
<Bio userId={id} />
</Suspense>
</Suspense>
React Compiler v1.0 (stable Oct 7, 2025) automates memoization — write plain components in NEW code. A build-time transform inserts the equivalent of useMemo/useCallback/React.memo via dataflow analysis, and can memoize conditionally and past early returns (which manual memoization cannot). With the compiler enabled, do not pre-emptively scatter memoization in new code. Precision: the React team does NOT say to strip existing manual memoization — useMemo/useCallback "can continue to be used with React Compiler as an escape hatch" for precise control, and for existing code they recommend leaving it in place or testing carefully before removing, since removal can change compiler output. Supports React 17+ (add react-compiler-runtime for pre-19).
<Activity> (React 19.2) hides UI without unmounting — replaces display:none and conditional-render hacks. With mode="hidden" it "hides the children, unmounts effects, and defers all updates until React has nothing left to work on" while preserving state and DOM, so revealing it is instant with no re-mount. This beats conditional rendering ({cond && <X/>}), which destroys state, and a CSS display:none wrapper, which preserves state but does NOT pause effects/free resources. Use it for tab panels that should keep scroll/input state and for pre-rendering routes during navigation.
import { Activity } from 'react'
function Tabs({ activeTab }: { activeTab: string }) {
return (
<>
<Activity mode={activeTab === 'profile' ? 'visible' : 'hidden'}>
<ProfilePanel />
</Activity>
<Activity mode={activeTab === 'settings' ? 'visible' : 'hidden'}>
<SettingsPanel />
</Activity>
</>
)
}
Verify Before Done