How to buildOctober 12, 20267 min read
Developer ReferenceStep 5 of 8

React hooks reference

In short

A hook is a React function whose name starts with use; it connects state, context, refs, effects or actions to a component. Below are the built-in React 19.3 hooks by group: signature, when you need it and a common mistake, plus a short TypeScript example for each key hook. Every example passed tsc, Vitest and a run in the browser.

ReferenceLevel: IntermediateReact Hooks Reference

After this you will be able to

  • find the signature of a built-in React 19 hook and the problem it solves
  • choose between useState and useReducer, useEffect and useLayoutEffect, useTransition and useDeferredValue
  • recognise a typical hook mistake by its symptom and fix it

Verified with

React 19.3.0TypeScript 6.0.3, 7.0.2Vite 8.3.4Vitest 5.0.3@testing-library/react 16.3.3eslint-plugin-react-hooks 7.1.1Node.js 24 LTS (24.18.0)

Checked against docs: October 12, 2026

Contents
  1. 01Rules of Hooks and the group map
  2. 02State: useState and useReducer
  3. 03Effects: useEffect, useLayoutEffect and relatives
  4. 04Refs and context: useRef, useContext, use
  5. 05Performance: useMemo, useCallback, useTransition, useDeferredValue
  6. 06Actions and forms: useActionState, useOptimistic, useFormStatus
  7. 07Other hooks: useId, useSyncExternalStore, use(promise)
  8. 08Common mistakes: symptom → cause

Rules of Hooks and the group map

React tells hooks apart by call order, so the rules are strict: call hooks only at the top level of a component or your own hook — before any early return, never inside conditions, loops, nested functions or try/catch. The exception is use: you can call it inside conditions and loops.

State

Hooks
useState, useReducer
Purpose
a component “remembers” data between renders

Context

Hooks
useContext, use
Purpose
read a value from a distant parent without props

Refs

Hooks
useRef, useImperativeHandle
Purpose
keep a value without re-rendering, access the DOM

Effects

Hooks
useEffect, useLayoutEffect, useInsertionEffect, useEffectEvent
Purpose
synchronise with external systems

Performance

Hooks
useMemo, useCallback, useTransition, useDeferredValue
Purpose
skip unnecessary work, separate urgent and background updates

Actions and forms

Hooks
useActionState, useOptimistic, useFormStatus (from react-dom)
Purpose
submission state, instant feedback

Other

Hooks
useId, useSyncExternalStore, useDebugValue
Purpose
ids for accessibility, external stores, DevTools labels
src/PriceWrong.tsx
import { useState } from "react";

// Wrong: a Hook after an early return — the call order changes between renders
export function Price({ amount }: { amount: number | null }) {
  if (amount === null) return null;
  const [currency, setCurrency] = useState("KZT");

  return (
    <button onClick={() => setCurrency(currency === "KZT" ? "USD" : "KZT")}>
      {amount} {currency}
    </button>
  );
}
eslint-plugin-react-hooks 7.1 reports: “React Hook "useState" is called conditionally”. A test showed the consequence too: pick USD, the price disappears and comes back — it is KZT again, the state was silently lost. With several hooks React throws “Rendered more hooks than during the previous render” or “Rendered fewer hooks than expected”.
src/Price.tsx
import { useState } from "react";

// Right: Hooks go before any early return, so the call order never changes
export function Price({ amount }: { amount: number | null }) {
  const [currency, setCurrency] = useState("KZT");
  if (amount === null) return null;

  return (
    <button onClick={() => setCurrency(currency === "KZT" ? "USD" : "KZT")}>
      {amount} {currency}
    </button>
  );
}
The same scenario in a test keeps USD.

State: useState and useReducer

useState

Signature
const [state, setState] = useState(initialState)
When
a single value the render depends on: an input, a flag, the selected item
Common mistake
reading the new value right after setState — state is a snapshot and changes on the next render; mutating an object instead of copying it

useReducer

Signature
const [state, dispatch] = useReducer(reducer, initialArg, init?)
When
many related fields and transitions: a cart, a form wizard, a task list
Common mistake
mutating state in the reducer or forgetting ...state — some fields become undefined
src/Counter.tsx
import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);

  function addThree() {
    // the updater gets the value after the previous queued calls
    setCount((c) => c + 1);
    setCount((c) => c + 1);
    setCount((c) => c + 1);
  }

  return <button onClick={addThree}>Count: {count}</button>;
}
Test: one click gives “Count: 3”. With setCount(count + 1) three times it would be +1: all three calls see the same snapshot.
src/todos.ts
export type Todo = { id: string; text: string; done: boolean };

export type TodoAction =
  | { type: "added"; id: string; text: string }
  | { type: "toggled"; id: string }
  | { type: "deleted"; id: string };

// a pure function: (current state, action) → next state
export function todosReducer(todos: Todo[], action: TodoAction): Todo[] {
  switch (action.type) {
    case "added":
      return [...todos, { id: action.id, text: action.text, done: false }];
    case "toggled":
      return todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
    case "deleted":
      return todos.filter((t) => t.id !== action.id);
  }
}
The reducer was tested on its own: the input array is frozen (Object.freeze) and stays unchanged. In a component: const [todos, dispatch] = useReducer(todosReducer, []) and dispatch({ type: "toggled", id }).
  • Pass an expensive initial value as a function: useState(() => createRows()), not useState(createRows()), or it is computed on every render.
  • Use setX(prev => …) when the next value depends on the previous one.
  • “Too many re-renders” usually means onClick={handleClick()} instead of onClick={handleClick}.
  • Snapshots and updates without mutation are covered in depth in the useState lesson.

Effects: useEffect, useLayoutEffect and relatives

useEffect

Signature
useEffect(setup, dependencies?)
When
synchronising with an external system: a subscription, a timer, a socket, a non-React widget
Common mistake
computing in an effect what can be computed during render; skipping dependencies; an effect that changes its own dependency loops forever

useLayoutEffect

Signature
useLayoutEffect(setup, dependencies?)
When
measuring layout before the frame is painted: tooltip position, block height
Common mistake
using it instead of useEffect “just in case” — it blocks painting; it does not run on the server

useInsertionEffect

Signature
useInsertionEffect(setup, dependencies?)
When
inserting styles before layout effects fire — for CSS-in-JS library authors
Common mistake
using it in application code

useEffectEvent

Signature
const onEvent = useEffectEvent(callback)
When
logic inside an effect that reads fresh props but must not re-run the effect
Common mistake
hiding dependencies with it; calling it during render or passing it to other components
src/Clock.tsx
import { useEffect, useState } from "react";

export function Clock() {
  const [now, setNow] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 1000);
    return () => clearInterval(id); // cleanup: before a re-run and on unmount
  }, []); // no reactive values inside — subscribe once

  return <time>{now.toLocaleTimeString("en-GB")}</time>;
}
Fake-timer test: the text changes after 1000 ms; after unmount there are 0 active timers. In the browser (Vite dev server, StrictMode) the clock ticks with no console warnings.
src/Tooltip.tsx
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";

type Props = { targetTop: number; children: ReactNode };

export function Tooltip({ targetTop, children }: Props) {
  const ref = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    // measure before the browser paints the frame — no flicker
    setHeight(ref.current!.getBoundingClientRect().height);
  }, []);

  // no room above — show it below the target
  const top = height > targetTop ? targetTop + 24 : targetTop - height;

  return (
    <div ref={ref} role="tooltip" style={{ position: "absolute", top }}>
      {children}
    </div>
  );
}
Test: with a 50px height the tooltip gets top: 50px, with 150px it goes below the target at 124px. In Edge: top = 100 − measured height (82px).

Refs and context: useRef, useContext, use

useRef

Signature
const ref = useRef(initialValue)
When
a DOM node, a timer id, a previous value — anything not needed for rendering
Common mistake
reading or writing ref.current during render; expecting a re-render after changing it — there will be none

useImperativeHandle

Signature
useImperativeHandle(ref, createHandle, dependencies?)
When
exposing a limited set of methods to the parent (focus, scrollTo)
Common mistake
exposing as methods what could be a prop: { open, close } instead of isOpen on a modal

useContext

Signature
const value = useContext(SomeContext)
When
theme, language, current user — data for many tree levels
Common mistake
putting the provider in the same component or below: the hook only sees providers above; a new value object on every render re-renders every consumer

use (context)

Signature
const value = use(SomeContext)
When
the same, but allowed after a condition or an early return
Common mistake
reading context with use is not supported in Server Components
src/SearchBox.tsx
import { useRef } from "react";

export function SearchBox() {
  const inputRef = useRef<HTMLInputElement>(null);

  return (
    <>
      <input ref={inputRef} aria-label="Search" />
      <button onClick={() => inputRef.current?.focus()}>Find</button>
    </>
  );
}
Test: after clicking the button, focus is in the search field.
src/theme.tsx
import { createContext, use, useContext, useState } from "react";

type Theme = "light" | "dark";
const ThemeContext = createContext<Theme>("light"); // the value when there is no provider above

export function ThemedButton() {
  const theme = useContext(ThemeContext);
  return <button className={`btn-${theme}`}>Theme: {theme}</button>;
}

function Hint({ show }: { show: boolean }) {
  if (!show) return null;
  const theme = use(ThemeContext); // use() may be called after a condition
  return <p>Hint for the theme {theme}</p>;
}

export function App() {
  const [theme, setTheme] = useState<Theme>("dark");

  return (
    // React 19: the context itself works as the provider
    <ThemeContext value={theme}>
      <ThemedButton />
      <Hint show />
      <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>Switch</button>
    </ThemeContext>
  );
}
Test: “Theme: dark” and the hint from use, then “Theme: light” after a click; ThemedButton without a provider gets the default light. In React 19 you can pass ref as a regular prop and write the provider as <ThemeContext value={…}>.

Performance: useMemo, useCallback, useTransition, useDeferredValue

useMemo

Signature
const value = useMemo(calculateValue, dependencies)
When
an expensive calculation; a stable object reference for memo or dependencies
Common mistake
useMemo(() => { a: 1 }, []) returns undefined — you need parentheses: () => ({ a: 1 }); wrapping everything

useCallback

Signature
const fn = useCallback(fn, dependencies)
When
the function goes to a memo component or into effect dependencies
Common mistake
no gain without memo on the receiver; without a dependency array the function is new on every render

useTransition

Signature
const [isPending, startTransition] = useTransition()
When
a non-blocking update and Actions: switching tabs, saving with a spinner
Common mistake
driving an input’s value through a transition; updating state after await without another startTransition

useDeferredValue

Signature
const deferred = useDeferredValue(value, initialValue?)
When
a heavy part of the UI lags behind typing while the input stays responsive
Common mistake
passing an object created during render — it is always “new”; expecting fewer server requests
src/ProductList.tsx
import { memo, useCallback, useMemo, useState } from "react";

type Product = { id: number; name: string };
type RowProps = { product: Product; onSelect: (id: number) => void };

const Row = memo(function Row({ product, onSelect }: RowProps) {
  return (
    <li>
      <button onClick={() => onSelect(product.id)}>{product.name}</button>
    </li>
  );
});

export function ProductList({ products }: { products: Product[] }) {
  const [query, setQuery] = useState("");
  const [selected, setSelected] = useState<number | null>(null);

  // recalculated only when products or query change
  const visible = useMemo(
    () => products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())),
    [products, query],
  );

  // the same function between renders — memo(Row) skips the rows
  const handleSelect = useCallback((id: number) => setSelected(id), []);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Filter" />
      <p>Selected: {selected ?? "—"}</p>
      <ul>
        {visible.map((p) => (
          <Row key={p.id} product={p} onSelect={handleSelect} />
        ))}
      </ul>
    </>
  );
}
Test: after selecting a product filter is not called again and the rows do not re-render (the name getter is not read); typing in the filter recomputes the list.
src/Quantity.tsx
import { useState, useTransition } from "react";
import { saveQuantity } from "./api";

export function Quantity() {
  const [qty, setQty] = useState(1);
  const [isPending, startTransition] = useTransition();

  function add() {
    startTransition(async () => {
      const saved = await saveQuantity(qty + 1);
      // wrap the update after await in startTransition again
      startTransition(() => setQty(saved));
    });
  }

  return (
    <>
      <button onClick={add} disabled={isPending}>+1</button>
      <output>{isPending ? "Saving…" : qty}</output>
    </>
  );
}
Test: while the request runs — “Saving…” and a disabled button, then 2. In the browser with a real 300 ms delay — the same.
src/Search.tsx
import { memo, useDeferredValue, useState } from "react";

const Results = memo(function Results({ items, query }: { items: string[]; query: string }) {
  const found = items.filter((item) => item.includes(query));
  return <ul>{found.map((item) => <li key={item}>{item}</li>)}</ul>;
});

export function Search({ items }: { items: string[] }) {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query); // the list catches up with typing in the background
  const isStale = query !== deferredQuery;

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search" />
      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <Results items={items} query={deferredQuery} />
      </div>
    </>
  );
}
Test: after typing “app” the list holds only “apple”.

Actions and forms: useActionState, useOptimistic, useFormStatus

useActionState

Signature
const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState, permalink?)
When
the result of a form submission or action: errors, server response, pending flag
Common mistake
calling dispatchAction outside an Action (not a form action, not in startTransition) — an error in development; expecting parallel runs — calls are queued

useOptimistic

Signature
const [optimistic, setOptimistic] = useOptimistic(value, reducer?)
When
show the result at once while the request is in flight: a like, a message, a cart item
Common mistake
calling the setter outside an Action — a warning, and the optimistic value flashes and disappears

useFormStatus

Signature
const { pending, data, method, action } = useFormStatus()
When
a button or field inside a form knows the form is submitting
Common mistake
calling it in the same component that renders <form>: it only sees a parent form
src/SubscribeForm.tsx
import { useActionState } from "react";
import { subscribe } from "./api";

type State = { ok: boolean; message: string } | null;

async function subscribeAction(_prev: State, formData: FormData): Promise<State> {
  const email = String(formData.get("email") ?? "");
  if (!email.includes("@")) return { ok: false, message: "Enter an email" };
  try {
    await subscribe(email);
    return { ok: true, message: "You are subscribed" };
  } catch {
    return { ok: false, message: "Something went wrong, try again later" };
  }
}

export function SubscribeForm() {
  const [state, formAction, isPending] = useActionState(subscribeAction, null);

  return (
    <form action={formAction}>
      <input name="email" aria-label="Email" />
      <button disabled={isPending}>{isPending ? "Sending…" : "Subscribe"}</button>
      {state && <p role="status">{state.message}</p>}
    </form>
  );
}
Test: “abc” gives “Enter an email”; a valid address gives “Sending…” and disabled, then “You are subscribed”; a rejected request shows the error message. The network call is a stub.
src/Chat.tsx
import { startTransition, useOptimistic, useState } from "react";
import { sendMessage, type Message } from "./api";

type Shown = Message & { sending?: boolean };

export function Chat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [shown, addShown] = useOptimistic<Shown[], string>(messages, (state, text) => [
    ...state,
    { id: "pending", text, sending: true },
  ]);

  function send(text: string) {
    startTransition(async () => {
      addShown(text); // the message shows at once while the request runs
      const saved = await sendMessage(text);
      startTransition(() => setMessages((m) => [...m, saved]));
    });
  }

  return (
    <>
      <button onClick={() => send("Hello!")}>Send</button>
      <ul>
        {shown.map((m) => (
          <li key={m.id}>
            {m.text} {m.sending && "(sending…)"}
          </li>
        ))}
      </ul>
    </>
  );
}
Test: right after the click — “Hello! (sending…)”, after the response — one message without the marker. In Edge with a 300 ms delay — the same.

Other hooks: useId, useSyncExternalStore, use(promise)

useId

Signature
const id = useId()
When
linking label, aria-describedby and a field; the same id on server and client render
Common mistake
using it as a list key or a cache key — those come from data

useSyncExternalStore

Signature
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
When
subscribing to an external store or a browser API: network, window size, a third-party store
Common mistake
getSnapshot returns a new object each time — the “The result of getSnapshot should be cached” error; subscribe defined inside the component — resubscribing on every render

use (promise)

Signature
const value = use(promise)
When
reading a promise result inside <Suspense>
Common mistake
creating the promise during render — it must be cached; wrapping use in try/catch — errors go to an Error Boundary

useDebugValue

Signature
useDebugValue(value, format?)
When
a label for your hook in React DevTools
Common mistake
computing an expensive value without format
src/PasswordField.tsx
import { useId } from "react";

export function PasswordField() {
  const id = useId(); // unique per instance, the same on the server and the client

  return (
    <>
      <label htmlFor={`${id}-input`}>Password</label>
      <input id={`${id}-input`} type="password" aria-describedby={`${id}-hint`} />
      <p id={`${id}-hint`}>At least 12 characters</p>
    </>
  );
}
Test: two fields on the page get different ids, getAllByLabelText("Password") finds both, and aria-describedby points to the hint.
src/useOnlineStatus.ts
import { useSyncExternalStore } from "react";

// declared outside the component — React does not resubscribe on every render
function subscribe(callback: () => void) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine, // client snapshot
    () => true, // snapshot for server rendering
  );
}
Test: an offline event switches the hook to false. In Edge, Playwright’s offline mode does the same.
src/Profile.tsx
import { Suspense, use } from "react";
import type { User } from "./api";

function UserName({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise); // the fallback shows until the promise settles
  return <p>{user.name}</p>;
}

// create the promise outside render: in a loader, a handler or a Server Component
export function Profile({ userPromise }: { userPromise: Promise<User> }) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <UserName userPromise={userPromise} />
    </Suspense>
  );
}
Test: “Loading…” first, then the name once the promise resolves. In the example the promise is created once at app start-up, so there is no uncached-promise warning.

Common mistakes: symptom → cause

the console shows the old value after setState

Cause
state is a snapshot of the current render
What to do
use the value you passed to the setter, or setX(prev => …)

the screen did not update after changing an object

Cause
the object was mutated in place, the reference is the same
What to do
create a copy: { ...obj, field }, map, filter

an effect runs forever

Cause
the effect changes state it depends on, or depends on an object created during render
What to do
move the calculation out of the effect, move the object inside the effect or into useMemo

an effect fired twice in development

Cause
StrictMode is checking your cleanup
What to do
write the cleanup function instead of disabling the check

memo does not help, the child re-renders

Cause
a new function or object in props
What to do
useCallback / useMemo for those props

an input lags or does not type

Cause
the input value is updated inside startTransition
What to do
a plain setState for the input, the heavy part via useDeferredValue

state reset or an error about the number of hooks

Cause
a hook is called after a condition or an early return
What to do
move all hooks to the top, enable eslint-plugin-react-hooks

“A component was suspended by an uncached promise”

Cause
the promise for use is created on every render
What to do
create it in a loader or handler, or cache it

For more on state, see the lesson “React: state and useState”; for effects, request races and StrictMode, see the useEffect article. Form hooks in practice are covered in the lesson on forms and validation in React.

How this was verified3 · passedchecked 12 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.