Cookbook · React · 2026

React — Everyday Patterns

The hooks and components you paste into every app — a fetch hook, the canonical controlled form, a debounce, localStorage state, toasts, an auth gate, optimistic UI. Function components and hooks only. Each recipe has the code, when to reach for it, and the gotcha that bites you at 5pm.

jQuery → React mindset In jQuery you command the DOM — find a node, change it, attach a handler. In React you describe the DOM as a function of state, and let it re-render. Stop reaching for "where do I put the value back into the input"; the input reads from state and writes through onChange. There is no $(...) grabbing elements after the fact — if you find yourself wanting one, you almost always want a piece of useState or a useRef instead. Hooks are your $.ajax, your $(document).ready, and your plugin state, all folded into plain functions you compose.
What's current in 2026 These snippets are function components + hooks — no class components (the one exception: error boundaries still need a class, noted below). useEffect appears here for client-side polling and subscriptions, which is what it's actually for — but for data fetching the better default is now Server Components (fetch on the server, no hook) or TanStack Query (caching, dedup, retries for free). The hand-rolled useFetch below is the honest DIY version you should understand before you graduate to those. All snippets are TypeScript.
On this shelf
  1. useFetch hook — typed fetch with AbortController
  2. useDebounce hook — debounce a value
  3. useLocalStorage hook — persisted state
  4. useToggle / useDisclosure — open/close state
  5. Controlled form + submit — the canonical form
  6. Async button — disable + spinner while a promise runs
  7. clsx-style className helper — conditional classes
  8. List with loading / empty / error — the four-state render
  9. Toast / notification context — fire messages from anywhere
  10. Auth context + useAuth — user, token, <RequireAuth>
  11. useOptimistic for instant UI — React 19 optimistic send
  12. Stable callbacks for memoized children — stop re-renders
  13. Scroll-to-bottom with useRef — keep a chat pinned

Custom hooks 4 recipes

useFetch — typed fetch with AbortController

When: you need a quick client-side GET and a {data, loading, error} shape, and you're not ready to add a data library. The DIY baseline.

hooks/useFetch.ts
import { useState, useEffect } from "react";

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

export function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({
    data: null, loading: true, error: null,
  });

  useEffect(() => {
    const ctrl = new AbortController();
    setState({ data: null, loading: true, error: null });

    fetch(url, { signal: ctrl.signal })
      .then(async (res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return (await res.json()) as T;
      })
      .then((data) => setState({ data, loading: false, error: null }))
      .catch((err: unknown) => {
        // an abort is expected on unmount — ignore it
        if (err instanceof DOMException && err.name === "AbortError") return;
        const msg = err instanceof Error ? err.message : "Request failed";
        setState({ data: null, loading: false, error: msg });
      });

    return () => ctrl.abort(); // cleanup: cancel in-flight on unmount / url change
  }, [url]);

  return state;
}

the AbortController cancels the request when the component unmounts or url changes, so a slow response can't call setState on a dead component or overwrite a newer result.

this version has no caching, no dedup, no retry, and re-fetches on every url change. For anything real, graduate to TanStack Query (useQuery) or fetch in a Server Component. Don't grow this into a framework.

useDebounce — debounce a value

When: a search box that hits the API on every keystroke. Debounce the value, then react to the debounced copy.

hooks/useDebounce.ts
import { useState, useEffect } from "react";

export function useDebounce<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id); // reset the timer on every change
  }, [value, delay]);

  return debounced;
}

// usage
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 400);
// fetch only when debouncedQuery settles
const { data } = useFetch<Hit[]>(`/api/search?q=${debouncedQuery}`);

the cleanup clears the previous timeout before the next keystroke schedules a new one, so the API call only fires once the user pauses — not on every letter.

you bind the input to query (instant, for typing) but fetch off debouncedQuery. Wire the input to the debounced value by mistake and the box feels laggy and broken.

useLocalStorage — persisted state

When: remember a theme, a filter, a sidebar-collapsed flag across reloads. Acts like useState but survives refresh.

hooks/useLocalStorage.ts
import { useState, useEffect } from "react";

export function useLocalStorage<T>(
  key: string,
  initial: T,
): [T, (v: T) => void] {
  const [value, setValue] = useState<T>(() => {
    // lazy initial read — runs once, guards SSR
    if (typeof window === "undefined") return initial;
    try {
      const raw = window.localStorage.getItem(key);
      return raw ? (JSON.parse(raw) as T) : initial;
    } catch {
      return initial;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // quota full or storage blocked — fail quietly
    }
  }, [key, value]);

  return [value, setValue];
}

the initializer is a function, so the JSON.parse runs once on mount instead of on every render. The typeof window guard keeps it from crashing during server rendering (Next.js).

on the server localStorage doesn't exist, so the first client render uses initial and may flash before hydration corrects it. For theme, set the value with an inline script in <head> to avoid the flash.

useToggle / useDisclosure — open/close state

When: modals, dropdowns, accordions, drawers — anything with an open/closed boolean and tidy open/close/toggle actions.

hooks/useDisclosure.ts
import { useState, useCallback } from "react";

export function useDisclosure(initial = false) {
  const [isOpen, setOpen] = useState(initial);

  const open   = useCallback(() => setOpen(true), []);
  const close  = useCallback(() => setOpen(false), []);
  const toggle = useCallback(() => setOpen((v) => !v), []);

  return { isOpen, open, close, toggle } as const;
}

// usage
const modal = useDisclosure();
return (
  <>
    <button onClick={modal.open}>Edit</button>
    {modal.isOpen && <Dialog onClose={modal.close} />}
  </>
);

the callbacks are wrapped in useCallback with empty deps, so they're stable references — safe to pass down to memoized children without retriggering renders.

the functional update setOpen((v) => !v) in toggle matters — using setOpen(!isOpen) reads a stale isOpen when called twice in one tick.

Forms & inputs 3 recipes

Controlled form + submit

When: any form. State holds the fields, onSubmit calls preventDefault, the button disables while submitting. The pattern you'll write a thousand times.

components/LoginForm.tsx
import { useState, type FormEvent } from "react";

export function LoginForm() {
  const [form, setForm] = useState({ email: "", password: "" });
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const update = (k: keyof typeof form) =>
    (e: React.ChangeEvent<HTMLInputElement>) =>
      setForm((f) => ({ ...f, [k]: e.target.value }));

  async function onSubmit(e: FormEvent) {
    e.preventDefault(); // stop the full-page reload
    setSubmitting(true);
    setError(null);
    try {
      await login(form);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Login failed");
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <input value={form.email} onChange={update("email")} type="email" />
      <input value={form.password} onChange={update("password")} type="password" />
      {error && <p role="alert">{error}</p>}
      <button disabled={submitting}>{submitting ? "Signing in…" : "Sign in"}</button>
    </form>
  );
}

one state object plus a curried update(key) handler scales to any number of fields without a handler per input. disabled={submitting} blocks the double-click double-submit for free.

forget e.preventDefault() and the browser does a native form POST and reloads the page — your React handler appears to "not run". For big/complex forms, reach for React Hook Form instead of hand-rolling validation.

Async button — disable + spinner while a promise runs

When: any button that triggers an async action (save, delete, send). It should disable itself and show progress so the user can't fire it twice.

components/AsyncButton.tsx
import { useState, type ReactNode } from "react";

interface Props {
  onClick: () => Promise<unknown>;
  children: ReactNode;
}

export function AsyncButton({ onClick, children }: Props) {
  const [pending, setPending] = useState(false);

  async function handle() {
    if (pending) return; // guard against double-submit
    setPending(true);
    try {
      await onClick();
    } finally {
      setPending(false); // always re-enable, even on throw
    }
  }

  return (
    <button onClick={handle} disabled={pending}>
      {pending && <Spinner />}
      {children}
    </button>
  );
}

the finally guarantees the button re-enables even if the promise rejects — so a failed save doesn't leave the UI stuck disabled forever.

if the parent unmounts mid-request, setPending(false) warns about updating an unmounted component. In React 19 it's harmless; if it nags you, track a mounted ref or move to a Server Action with useTransition.

clsx-style className helper

When: conditional classes — active tabs, error states, variants — without ternary-and-template-string spaghetti in JSX.

lib/cn.ts
type ClassValue = string | false | null | undefined | Record<string, boolean>;

export function cn(...parts: ClassValue[]): string {
  const out: string[] = [];
  for (const p of parts) {
    if (!p) continue;
    if (typeof p === "string") out.push(p);
    else for (const [k, on] of Object.entries(p)) if (on) out.push(k);
  }
  return out.join(" ");
}

// usage
<button
  className={cn(
    "btn",
    isActive && "btn--active",
    { "btn--danger": variant === "danger", "btn--lg": large },
  )}
/>

falsy parts (false, null, undefined) are skipped, so cond && "cls" just works. Readable, no leftover double-spaces.

don't reinvent this in every project — the real clsx (or classnames) package is ~200 bytes and battle-tested. With Tailwind, pair it with tailwind-merge so conflicting utilities resolve correctly.

UI patterns 3 recipes

List with loading / empty / error states

When: rendering any fetched list. Every list has four states — loading, error, empty, data — and shipping only "data" is the classic junior bug.

components/UserList.tsx
export function UserList() {
  const { data, loading, error } = useFetch<User[]>("/api/users");

  if (loading) return <Skeleton rows={5} />;
  if (error)   return <ErrorState message={error} />;
  if (!data || data.length === 0)
    return <EmptyState label="No users yet" />;

  return (
    <ul>
      {data.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

order the guards loading → error → empty → data and return early. The render path stays flat and every state is impossible to forget because it's literally in the way of the data.

the empty check must come after loading — an empty array during loading isn't "no users", it's "not loaded yet". And always give key a stable id, never the array index.

Stable callbacks for memoized children

When: a heavy list re-renders on every parent keystroke because the row's onSelect prop is a new function each render. useCallback + React.memo stops it.

components/Board.tsx
import { useState, useCallback, memo } from "react";

const Row = memo(function Row({ item, onSelect }: RowProps) {
  // expensive render — only re-runs if props actually change
  return <li onClick={() => onSelect(item.id)}>{item.label}</li>;
});

export function Board({ items }: { items: Item[] }) {
  const [selected, setSelected] = useState<string | null>(null);

  // stable identity across renders → memo'd Rows don't re-render
  const onSelect = useCallback((id: string) => setSelected(id), []);

  return (
    <ul>
      {items.map((it) => (
        <Row key={it.id} item={it} onSelect={onSelect} />
      ))}
    </ul>
  );
}

memo skips a re-render when props are referentially equal; useCallback keeps onSelect the same reference so the comparison passes. The pair only helps together.

don't sprinkle useCallback/memo everywhere — they cost memory and add noise, and only pay off for genuinely heavy or large lists. The React Compiler (stable in 2026) auto-memoizes for you, so on a Compiler-enabled project you can often delete these entirely.

Scroll-to-bottom with useRef

When: a chat or log view that should stay pinned to the newest message as items arrive (DocChat's transcript).

components/Transcript.tsx
import { useRef, useEffect } from "react";

export function Transcript({ messages }: { messages: Message[] }) {
  const bottomRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // re-run whenever the message count changes
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages.length]);

  return (
    <div className="transcript">
      {messages.map((m) => (
        <Bubble key={m.id} message={m} />
      ))}
      <div ref={bottomRef} /> // invisible scroll anchor at the end
    </div>
  );
}

an empty sentinel <div ref> at the bottom is simpler than computing scrollTop = scrollHeight — let the browser scroll the anchor into view. The optional chaining survives the first render when the ref is still null.

depend on messages.length, not messages — a new array identity on every render would scroll on edits too. If the user has scrolled up to read history, auto-scrolling yanks them down; gate it on "am I already near the bottom".

Async UI & context 3 recipes

Toast / notification context

When: you want to fire "Saved" or "Something went wrong" from any component without threading props. A provider plus a useToast() hook.

context/ToastContext.tsx
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";

interface Toast { id: number; message: string; }
type ToastApi = { notify: (message: string) => void };

const ToastCtx = createContext<ToastApi | null>(null);

export function ToastProvider({ children }: { children: ReactNode }) {
  const [toasts, setToasts] = useState<Toast[]>([]);

  const notify = useCallback((message: string) => {
    const id = Date.now();
    setToasts((t) => [...t, { id, message }]);
    setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3000);
  }, []);

  return (
    <ToastCtx.Provider value={{ notify }}>
      {children}
      <div className="toast-stack">
        {toasts.map((t) => (
          <div key={t.id} className="toast">{t.message}</div>
        ))}
      </div>
    </ToastCtx.Provider>
  );
}

export function useToast(): ToastApi {
  const ctx = useContext(ToastCtx);
  if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
  return ctx;
}

the hook throws a clear error when you forget the provider — far better than a silent null that explodes three calls deep. Wrap your app once near the root.

use a real unique id (Date.now() or a counter) for the key and removal — two toasts in the same millisecond collide if you cut corners. Don't store toasts in a global outside React or they won't trigger re-renders.

Auth context + useAuth + <RequireAuth>

When: the app needs a current user and token everywhere, a clean useAuth() hook, and a wrapper that gates protected screens.

context/AuthContext.tsx
import { createContext, useContext, useState, type ReactNode } from "react";
import { Navigate } from "react-router";

interface AuthState {
  user: User | null;
  token: string | null;
  login: (u: User, token: string) => void;
  logout: () => void;
}

const AuthCtx = createContext<AuthState | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [token, setToken] = useState<string | null>(null);

  const login = (u: User, t: string) => { setUser(u); setToken(t); };
  const logout = () => { setUser(null); setToken(null); };

  return (
    <AuthCtx.Provider value={{ user, token, login, logout }}>
      {children}
    </AuthCtx.Provider>
  );
}

export function useAuth(): AuthState {
  const ctx = useContext(AuthCtx);
  if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
  return ctx;
}

export function RequireAuth({ children }: { children: ReactNode }) {
  const { user } = useAuth();
  if (!user) return <Navigate to="/login" replace />;
  return <>{children}</>;
}

centralising auth in context means components read useAuth() instead of passing user/token down. <RequireAuth> wraps a route's element so the redirect lives in one place.

don't keep the token only in React state — it's gone on refresh. Persist it (httpOnly cookie is safest; localStorage is the pragmatic web-app choice) and rehydrate on mount. In Next.js, prefer server-side session checks over a client context for protected pages.

useOptimistic for instant UI (React 19)

When: a chat send should appear immediately, before the server confirms — then reconcile when the real message arrives. React 19's useOptimistic does exactly this (DocChat).

components/ChatInput.tsx
import { useOptimistic, useRef } from "react";

export function Chat({ messages, sendAction }: ChatProps) {
  // optimistic copy = real messages + any pending ones
  const [optimistic, addOptimistic] = useOptimistic(
    messages,
    (state, pending: string) => [
      ...state,
      { id: "temp", text: pending, pending: true },
    ],
  );

  const formRef = useRef<HTMLFormElement>(null);

  async function formAction(data: FormData) {
    const text = data.get("text") as string;
    addOptimistic(text);        // show it now
    formRef.current?.reset();
    await sendAction(text);       // server confirms → messages updates → temp drops
  }

  return (
    <>
      {optimistic.map((m) => (
        <Bubble key={m.id} message={m} dimmed={m.pending} />
      ))}
      <form ref={formRef} action={formAction}>
        <input name="text" />
      </form>
    </>
  );
}

React holds the optimistic state until the action resolves, then automatically discards it and re-renders from the real messages — no manual rollback. Dim the pending bubble so the user knows it's in flight.

useOptimistic only works inside an action/transition (a form action or useTransition). Call addOptimistic outside one and React throws. If the send fails, the optimistic item just vanishes — surface the error (a toast) or it looks like the message silently disappeared.

The one class you still write Everything here is hooks — but error boundaries still require a class component, because there's no hook equivalent for componentDidCatch / getDerivedStateFromError yet. Write one <ErrorBoundary> class, wrap your app (or risky subtrees) in it, and never write a class again. Most teams use the react-error-boundary package so even that is off-the-shelf.
Source Patterns and APIs verified against react.dev — Hooks reference (useState, useEffect, useRef, useCallback, useContext, useOptimistic, memo), "You Might Not Need an Effect", and the React 19 + React Compiler release notes. Data-fetching guidance reflects the current Server Components / TanStack Query default.