../

React hooks

Every built-in hook in React 19.2, grouped by job, plus the rules, effect pitfalls, custom hook conventions and a set of typed custom hooks to paste. Component-level concepts are in React; prop and event typing is in TypeScript & React.

All hooks at a glance

HookImportReturnsReach for it to
useState(init)react[value, setValue]remember a value between renders
useReducer(reducer, init)react[state, dispatch]centralize many related updates
useContext(Ctx)reactcontext valueread context (top level only)
use(ctxOrPromise)reactvalueread context or a promise; allowed in if and loops
useRef(init)react{ current }hold a DOM node or a value that doesn't render
useImperativeHandle(ref, make)reactvoidexpose a custom handle through ref
useEffect(fn, deps?)reactvoidsync with an external system after paint
useLayoutEffect(fn, deps?)reactvoidmeasure or mutate layout before paint
useInsertionEffect(fn, deps?)reactvoidinject <style> tags (CSS-in-JS libraries)
useEffectEvent(fn)reacteffect event functionread latest props/state inside an effect without re-running it
useMemo(fn, deps)reactcached valueskip an expensive recomputation
useCallback(fn, deps)reactcached functionkeep a function's identity for memo children or deps
useTransition()react[isPending, start]mark updates or async actions non-urgent
useDeferredValue(v, init?)reactlagging vdefer heavy UI driven by fast input
useId()reactstringunique ids for htmlFor, aria-* (SSR-safe)
useSyncExternalStore(sub, get, getServer?)reactsnapshotsubscribe to a non-React store or browser API
useDebugValue(v, fmt?)reactvoidlabel a custom hook in DevTools
useActionState(fn, init, permalink?)react[state, dispatch, isPending]state that updates through an action
useOptimistic(value, reducer?)react[optimistic, set]show the expected result while an action runs
useFormStatus()react-dom{ pending, data, method, action }pending state of the parent <form>

Rules of hooks

RuleDetail
Call at the top levelnot inside if, loops, nested functions, try/catch, or after an early return
Call from React functions onlycomponents and custom hooks, never plain functions or classes
Same order every renderReact matches hook calls to state slots by call order
use is the exceptionit may sit in if and loops, but still not in try/catch
Don't pick hooks dynamicallyconst h = cond ? useA : useB breaks the order too
Components are purehooks' arguments and return values are treated as immutable
import { useEffect, useState } from "react";
 
function Profile({ id }: { id: string | null }) {
  // ❌ conditional hook: order changes when id flips
  // if (!id) return null;
  // const [tab, setTab] = useState("posts");
 
  // ✅ hooks first, branch afterward
  const [tab, setTab] = useState("posts");
  useEffect(() => {
    if (!id) return; // condition inside the hook
    document.title = `${id} · ${tab}`;
  }, [id, tab]);
 
  if (!id) return null;
  return (
    <button onClick={() => setTab("likes")}>{tab}</button>
  );
}

State: useState & useReducer

FormUse
useState(value)initial value, read on the first render only
useState(() => compute())lazy initializer: pass the function, don't call it
set(next)replace; same value by Object.is skips the re-render
set((prev) => next)derive from the queued value; needed for repeated or async updates
useReducer(reducer, initialArg)many updates to one state, testable pure transitions
useReducer(reducer, arg, init)init(arg) builds the initial state once
import { useReducer, useState } from "react";
 
type Cart = { items: Record<string, number> };
type Act =
  | { type: "add"; sku: string }
  | { type: "remove"; sku: string };
 
function reducer(s: Cart, a: Act): Cart {
  const n = s.items[a.sku] ?? 0;
  const next = a.type === "add" ? n + 1 : n - 1;
  const { [a.sku]: _, ...rest } = s.items;
  return {
    items: next > 0 ? { ...rest, [a.sku]: next } : rest,
  };
}
 
const init = (skus: string[]): Cart => ({
  items: Object.fromEntries(skus.map((s) => [s, 1])),
});
 
function Checkout({ saved }: { saved: string[] }) {
  // lazy initializer: reads storage once, not every render
  const [note, setNote] = useState(
    () => localStorage.getItem("note") ?? "",
  );
  // object state: replace, never mutate
  const [form, setForm] = useState({ name: "", age: 0 });
  // init(saved) runs once
  const [cart, dispatch] = useReducer(reducer, saved, init);
 
  return (
    <form>
      <input value={form.name} onChange={(e) =>
        setForm((f) => ({ ...f, name: e.target.value }))} />
      <textarea value={note}
        onChange={(e) => setNote(e.target.value)} />
      <button type="button"
        onClick={() => dispatch({ type: "add", sku: "b2" })}>
        Add ({Object.keys(cart.items).length})
      </button>
    </form>
  );
}
PickWhen
useStateindependent values, simple updates
useReducernext state depends on several fields; many event types; logic worth unit-testing
External storestate shared across distant components with frequent updates

Typing reducers with discriminated unions is covered in TypeScript & React: Hooks.

Context & use

import { createContext, use, useContext } from "react";
import type { ReactNode } from "react";
 
const Locale = createContext("en");
 
function Price(p: { cents: number; showLocale: boolean }) {
  const fmt = (l: string) =>
    new Intl.NumberFormat(l, {
      style: "currency",
      currency: "EUR",
    }).format(p.cents / 100);
 
  if (!p.showLocale) return <span>{fmt("en")}</span>;
  const locale = use(Locale); // fine after an early return
  return <span>{fmt(locale)}</span>;
}
 
function Header(): ReactNode {
  const locale = useContext(Locale); // top level only
  return <p lang={locale}>{locale}</p>;
}
use(x) withBehavior
A contextlike useContext, but callable conditionally
A promisesuspends until it resolves; rejects to the nearest error boundary
A promise created in rendera new promise every render: infinite suspense; create it in a Server Component, loader or cache
Inside try/catchnot allowed; use an error boundary

Refs: useRef & useImperativeHandle

RuleDetail
useRef<T>(null)DOM refs; current is T | null until commit
useRef(value)a mutable box; writing it doesn't re-render
No reads or writes in renderexcept lazy init guarded by === null
Keep refs localexpose a narrow API with useImperativeHandle, not the raw node
import { useRef } from "react";
 
class VideoPlayer {
  constructor(readonly src: string) {}
  play() {}
}
 
function Player({ src }: { src: string }) {
  // lazy init: construct once, not every render
  const player = useRef<VideoPlayer | null>(null);
  if (player.current === null) {
    player.current = new VideoPlayer(src);
  }
  const box = useRef<HTMLDivElement>(null);
 
  return (
    <div ref={box} onClick={() => player.current?.play()} />
  );
}

useImperativeHandle with a typed handle is in TypeScript & React: Refs.

Effect hooks

HookRunsBlocks paintUse for
useInsertionEffectbefore any layout effectsyesstyle injection only; no refs, no setState
useLayoutEffectafter DOM mutation, before paintyesmeasure layout, then position (tooltips, popovers)
useEffectafter paint (sooner if triggered by a discrete event)nosubscriptions, network, timers, non-React widgets
useEffectEventnever on its ownn/aa function effects can call that sees the latest render

None of them run on the server.

import {
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
 
function Tooltip({ text }: { text: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const [above, setAbove] = useState(false);
 
  // measure before paint: no flicker at the wrong spot
  useLayoutEffect(() => {
    const r = ref.current?.getBoundingClientRect();
    if (r) setAbove(r.bottom > window.innerHeight);
  }, [text]);
 
  return (
    <div ref={ref} data-above={above}>{text}</div>
  );
}
 
function Tracker(p: { page: string; userId: string }) {
  // latest userId, but only a page change re-logs
  const log = useEffectEvent((page: string) => {
    navigator.sendBeacon("/log", `${p.userId}:${page}`);
  });
  useEffect(() => log(p.page), [p.page]);
  return null;
}
useEffectEvent ruleDetail
Call it only from effectsor from other effect events of the same component
Never list it in depsits identity changes every render on purpose
Don't pass it to children or other hooksdefine it next to the effect that uses it
Not a lint silenceruse it for logic that is really an "event", not to drop a real dependency

Effect dependencies & cleanup

Deps argumentEffect runs
omittedafter every render
[]after mount only (twice in dev StrictMode: mount, cleanup, mount)
[a, b]after mount and whenever a or b changes by Object.is

Cleanup runs before the next run of the effect and on unmount, using the old render's values.

PitfallFix
Object or function created in render as a depchanges every render; create it inside the effect, or depend on its primitive fields
Omitting a dep the effect readsstale values; list it, or move the read into useEffectEvent
async effect functioneffects return cleanup or nothing; define an async function inside and call it
Fetch responses arriving out of orderabort in cleanup, or ignore results after cleanup
setState straight in the effect bodyan extra render; derive the value during render instead
Interval reading statesetX((x) => x + 1), or useEffectEvent for the callback
Missing cleanupleaked listeners, sockets and timers; the dev double-run exposes these
useEffect + setState to follow propscompute during render or reset with key
import { useEffect, useState } from "react";
 
type Opts = { url: string; room: string };
declare function createConn(o: Opts): {
  connect(): void;
  disconnect(): void;
};
 
function Chat({ url, room }: Opts) {
  // ❌ options = { url, room } in render: new object each
  //    render, so the effect reconnects every render
 
  useEffect(() => {
    const conn = createConn({ url, room }); // ✅ inside
    conn.connect();
    return () => conn.disconnect();
  }, [url, room]);
 
  return <h2>{room}</h2>;
}
 
function Bio({ id }: { id: string }) {
  const [bio, setBio] = useState<string | null>(null);
  useEffect(() => {
    let ignore = false; // guards against stale responses
    fetch(`/api/bio/${id}`)
      .then((r) => r.text())
      .then((t) => {
        if (!ignore) setBio(t);
      })
      .catch(() => {});
    return () => {
      ignore = true;
    };
  }, [id]);
  return <p>{bio ?? "Loading..."}</p>;
}

Lifecycle flow

React Hook Flow Diagram by Donavon West: for mount, update and unmount, the order is lazy initializers (mount only), render, React updates the DOM, clean up layout effects, run layout effects, browser paints, clean up effects, run effects

Chart: hook-flow (opens in a new tab) by Donavon West (MIT). Top to bottom, one column per phase:

StepMountUpdateUnmount
Lazy initializers (useState(() => …), useReducer(r, arg, init))runskipped-
Render (component body)runrun-
React updates the DOMinsertpatchremove
Layout effects (useLayoutEffect)runold cleanup, then new run (deps changed)cleanup
Browser paintsyesyesyes
Effects (useEffect)runold cleanup, then new run (deps changed)cleanup

Performance hooks

With the React Compiler on, skip manual useMemo/useCallback in new code; keep them where an effect depends on identity or a third-party library compares references.

HookCaches or schedulesNotes
useMemo(() => v, deps)a valuethe function must be pure; React may drop the cache
useCallback(fn, deps)a functionsame as useMemo(() => fn, deps)
useTransition()updates inside start as low prioritystart accepts async functions (actions)
useDeferredValue(v, init?)a copy of v that trails urgent rendersinit is shown on the first render (19)
import {
  memo,
  useDeferredValue,
  useState,
  useTransition,
} from "react";
 
declare function search(q: string): string[]; // slow
declare function save(text: string): Promise<void>;
 
const Results = memo(function Results(p: { q: string }) {
  const rows = search(p.q);
  return <ul>{rows.map((r) => <li key={r}>{r}</li>)}</ul>;
});
 
function Search() {
  const [q, setQ] = useState("");
  const deferred = useDeferredValue(q); // lags while typing
  const stale = q !== deferred;
  return (
    <>
      <input
        value={q}
        onChange={(e) => setQ(e.target.value)}
      />
      <div style={{ opacity: stale ? 0.5 : 1 }}>
        <Results q={deferred} />
      </div>
    </>
  );
}
 
function SaveButton({ text }: { text: string }) {
  const [isPending, startTransition] = useTransition();
  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(() => save(text))}
    >
      {isPending ? "Saving..." : "Save"}
    </button>
  );
}

useDeferredValue only helps if the slow child is memoized (memo or the compiler); otherwise the parent re-render redoes the work anyway.

useId, useSyncExternalStore & useDebugValue

import {
  useDebugValue,
  useId,
  useSyncExternalStore,
} from "react";
 
function Field({ label }: { label: string }) {
  const id = useId(); // stable, matches server and client
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} aria-describedby={`${id}-hint`} />
      <small id={`${id}-hint`}>Required</small>
    </>
  );
}
 
function subscribe(onChange: () => void) {
  window.addEventListener("online", onChange);
  window.addEventListener("offline", onChange);
  return () => {
    window.removeEventListener("online", onChange);
    window.removeEventListener("offline", onChange);
  };
}
 
export function useOnline(): boolean {
  const online = useSyncExternalStore(
    subscribe, // stable: declared outside the component
    () => navigator.onLine, // client snapshot
    () => true, // server snapshot (SSR, hydration)
  );
  useDebugValue(online ? "Online" : "Offline");
  return online;
}
useSyncExternalStore ruleWhy
getSnapshot returns the same value while nothing changeda new object each call loops forever; return primitives or cached objects
subscribe is stablea new function each render resubscribes every render
Provide getServerSnapshotrequired for SSR; mismatch with the client value re-renders after hydration
Don't use useId for list keyskeys come from data

Actions: useActionState, useOptimistic, useFormStatus

import { startTransition, useActionState } from "react";
import { useFormStatus } from "react-dom";
 
declare function addToCart(sku: string): Promise<number>;
 
function AddButton({ sku }: { sku: string }) {
  const [count, add, isPending] = useActionState(
    async (_prev: number, s: string) => addToCart(s),
    0,
  );
  return (
    <button
      disabled={isPending}
      // outside a form: dispatch inside a transition
      onClick={() => startTransition(() => add(sku))}
    >
      In cart: {count}
    </button>
  );
}
 
function Submit() {
  const { pending, data } = useFormStatus(); // parent form
  const email = data?.get("email");
  return (
    <button disabled={pending}>
      {pending ? `Sending ${String(email)}...` : "Send"}
    </button>
  );
}
HookGotcha
useActionStatedispatches queue and run one after another; each gets the previous result
useActionStatecalling dispatch outside a transition or action prop warns and isPending stays false
useActionStatea thrown error cancels queued dispatches; return error state for expected failures
useOptimisticthe setter only works inside a transition or action; the value reverts when it settles
useFormStatusreads the parent <form> only; calling it in the form's own component returns idle

The full form example with a typed server action and useOptimistic is in TypeScript & React: Forms & actions.

Custom hooks

ConventionWhy
Name starts with use + capital letterthe linter and compiler treat it as a hook
It calls at least one hookotherwise make it a plain function (no use prefix)
Shares logic, not stateeach call gets its own state; share state via context or a store
Accept values, return valuesuseChatRoom({ roomId }) over useMount(fn)-style lifecycle wrappers
Callbacks from callerswrap in useEffectEvent so they don't re-run your effects
Return a tuple as const or an objecttuple for 2 values (renameable), object for more
Name by purposeuseOnlineStatus, not useEffectOnce
Test with renderHookfrom @testing-library/react; see Testing
use-previous.test.ts
import { expect, test } from "bun:test";
import { renderHook } from "@testing-library/react";
import { usePrevious } from "./use-previous";
 
test("returns the previous distinct value", () => {
  const { result, rerender } = renderHook(
    ({ v }) => usePrevious(v),
    { initialProps: { v: 1 } },
  );
  expect(result.current).toBeUndefined();
  rerender({ v: 2 });
  expect(result.current).toBe(1);
  rerender({ v: 2 }); // same value: unchanged
  expect(result.current).toBe(1);
});

eslint-plugin-react-hooks

v7 ships the classic hooks rules plus the React Compiler's diagnostics in one recommended preset. Use it even without the compiler.

bun add -d eslint eslint-plugin-react-hooks
eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";
import { defineConfig } from "eslint/config";
 
export default defineConfig([
  reactHooks.configs.flat.recommended,
  {
    rules: {
      "react-hooks/exhaustive-deps": [
        "warn",
        { additionalHooks: "(useIsomorphicLayoutEffect)" },
      ],
    },
  },
]);
RuleCatches
rules-of-hooksconditional or nested hook calls, hooks in non-hook functions
exhaustive-depsmissing or needless deps in effects, useMemo, useCallback
set-state-in-effectsynchronous setState in an effect body
set-state-in-renderunconditional setState during render (infinite loop)
refsreading or writing ref.current during render
purityimpure calls in render (Date.now(), Math.random())
immutabilitymutating props, state or hook return values
static-componentscomponents defined inside other components
preserve-manual-memoizationmanual memo the compiler can't keep
incompatible-librarylibraries known not to work with memoization

recommended-latest adds experimental rules. On ESLint 8 legacy config, extend plugin:react-hooks/recommended. Next.js's eslint-config-next already includes the plugin.

Recipes

useDebounce

Delay a fast-changing value (search input) until it stops changing for ms.

use-debounce.ts
import { useEffect, useState } from "react";
 
export function useDebounce<T>(value: T, ms = 300): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), ms);
    return () => clearTimeout(id);
  }, [value, ms]);
  return debounced;
}
 
// const q = useDebounce(input, 250);

useLocalStorage

Persisted state that stays in sync across components and tabs; validate with Zod if the stored shape can change.

use-local-storage.ts
import { useCallback, useSyncExternalStore } from "react";
const EVT = "local-storage"; // same-tab updates
function subscribe(cb: () => void) {
  const ac = new AbortController();
  const opts = { signal: ac.signal };
  addEventListener("storage", cb, opts); // other tabs
  addEventListener(EVT, cb, opts);
  return () => ac.abort();
}
export function useLocalStorage<T>(key: string, init: T) {
  const raw = useSyncExternalStore(
    subscribe,
    () => localStorage.getItem(key),
    () => null, // server snapshot
  );
  let value = init;
  try {
    if (raw !== null) value = JSON.parse(raw) as T;
  } catch {} // corrupt entry: fall back
  const set = useCallback((next: T) => {
    localStorage.setItem(key, JSON.stringify(next));
    dispatchEvent(new Event(EVT));
  }, [key]);
  return [value, set] as const;
}

useMediaQuery

React to a CSS media query (prefers-reduced-motion, breakpoints) without a resize listener.

use-media-query.ts
import { useCallback, useSyncExternalStore } from "react";
 
export function useMediaQuery(query: string, ssr = false) {
  const subscribe = useCallback(
    (cb: () => void) => {
      const mql = matchMedia(query);
      mql.addEventListener("change", cb);
      return () => mql.removeEventListener("change", cb);
    },
    [query],
  );
  return useSyncExternalStore(
    subscribe,
    () => matchMedia(query).matches,
    () => ssr, // server guess
  );
}
 
// const reduce = useMediaQuery("(prefers-reduced-motion)");

useInterval

A declarative setInterval whose callback always sees fresh state; pass null to pause.

use-interval.ts
import { useEffect, useEffectEvent } from "react";
 
export function useInterval(
  callback: () => void,
  ms: number | null,
): void {
  const tick = useEffectEvent(callback);
  useEffect(() => {
    if (ms === null) return; // paused
    const id = setInterval(() => tick(), ms);
    return () => clearInterval(id);
  }, [ms]);
}
 
// useInterval(() => setSecs(secs + 1),
//   running ? 1000 : null);

usePrevious

The previous distinct value of a prop or state, for "changed from X to Y" logic.

use-previous.ts
import { useState } from "react";
 
export function usePrevious<T>(value: T): T | undefined {
  const [current, setCurrent] = useState(value);
  const [previous, setPrevious] = useState<T>();
  if (!Object.is(current, value)) {
    // adjusting state during render: React re-renders
    // immediately, before children, with no extra paint
    setPrevious(current);
    setCurrent(value);
  }
  return previous;
}

useOnClickOutside

Close a popover or menu when the user presses anywhere outside it.

use-on-click-outside.ts
import { useEffect, useEffectEvent } from "react";
import type { RefObject } from "react";
 
export function useOnClickOutside(
  ref: RefObject<HTMLElement | null>,
  handler: (e: PointerEvent) => void,
): void {
  const onOutside = useEffectEvent(handler);
  useEffect(() => {
    const listener = (e: PointerEvent) => {
      const el = ref.current;
      // composedPath also works across shadow DOM
      if (el && !e.composedPath().includes(el)) {
        onOutside(e);
      }
    };
    document.addEventListener("pointerdown", listener);
    return () =>
      document.removeEventListener("pointerdown", listener);
  }, [ref]);
}

useFetch

Small client-side fetch that aborts on change or unmount, validates the result and derives loading instead of setting it; use a query library once you need caching or retries.

use-fetch.ts
import { useEffect, useEffectEvent, useState } from "react";
type State<T> = { status: "loading" }
  | { status: "ok"; data: T }
  | { status: "error"; error: unknown };
export function useFetch<T>(
  url: string, parse: (json: unknown) => T, // Schema.parse
): State<T> {
  const [res, setRes] = useState<[string, State<T>]>();
  const toData = useEffectEvent(parse);
  useEffect(() => {
    const ctrl = new AbortController();
    const settle = (s: State<T>) => {
      if (!ctrl.signal.aborted) setRes([url, s]);
    };
    fetch(url, { signal: ctrl.signal })
      .then(async (r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        const data = toData(await r.json());
        settle({ status: "ok", data });
      })
      .catch((error) => settle({ status: "error", error }));
    return () => ctrl.abort();
  }, [url]);
  return res?.[0] === url ? res[1] : { status: "loading" };
}

useEventListener

Typed window listener whose handler can change freely without re-subscribing.

use-event-listener.ts
import { useEffect, useEffectEvent } from "react";
 
export function useEventListener<
  K extends keyof WindowEventMap,
>(
  type: K,
  handler: (e: WindowEventMap[K]) => void,
  { capture, passive }: AddEventListenerOptions = {},
): void {
  const onEvent = useEffectEvent(handler);
  useEffect(() => {
    const opts = { capture, passive };
    const listener = (e: WindowEventMap[K]) => onEvent(e);
    addEventListener(type, listener, opts);
    return () => removeEventListener(type, listener, opts);
  }, [type, capture, passive]);
}
 
// useEventListener("keydown", (e) => e.key === "Escape"
//   && close());

References