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.
After this you will be able to
- find the signature of a built-in React 19 hook and the problem it solves
- choose between
useStateanduseReducer,useEffectanduseLayoutEffect,useTransitionanduseDeferredValue - recognise a typical hook mistake by its symptom and fix it
Know beforehand
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
- Rules of Hooks and the group map
- State: useState and useReducer
- Effects: useEffect, useLayoutEffect and relatives
- Refs and context: useRef, useContext, use
- Performance: useMemo, useCallback, useTransition, useDeferredValue
- Actions and forms: useActionState, useOptimistic, useFormStatus
- Other hooks: useId, useSyncExternalStore, use(promise)
- Common 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(fromreact-dom)- Purpose
- submission state, instant feedback
Other
- Hooks
useId,useSyncExternalStore,useDebugValue- Purpose
- ids for accessibility, external stores, DevTools labels
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”.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>
);
}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
statein the reducer or forgetting...state— some fields becomeundefined
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>;
}setCount(count + 1) three times it would be +1: all three calls see the same snapshot.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);
}
}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()), notuseState(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 ofonClick={handleClick}. - Snapshots and updates without mutation are covered in depth in the
useStatelesson.
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
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>;
}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>
);
}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.currentduring 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 ofisOpenon 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
valueobject 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
useis not supported in Server Components
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>
</>
);
}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>
);
}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
memoor dependencies - Common mistake
useMemo(() => { a: 1 }, [])returnsundefined— you need parentheses:() => ({ a: 1 }); wrapping everything
useCallback
- Signature
const fn = useCallback(fn, dependencies)- When
- the function goes to a
memocomponent or into effect dependencies - Common mistake
- no gain without
memoon 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
awaitwithout anotherstartTransition
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
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>
</>
);
}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.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>
</>
);
}disabled button, then 2. In the browser with a real 300 ms delay — the same.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>
</>
);
}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
dispatchActionoutside an Action (not a formaction, not instartTransition) — 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
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>
);
}disabled, then “You are subscribed”; a rejected request shows the error message. The network call is a stub.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>
</>
);
}Other hooks: useId, useSyncExternalStore, use(promise)
useId
- Signature
const id = useId()- When
- linking
label,aria-describedbyand a field; the same id on server and client render - Common mistake
- using it as a list
keyor 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
getSnapshotreturns a new object each time — the “The result of getSnapshot should be cached” error;subscribedefined 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
useintry/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
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>
</>
);
}getAllByLabelText("Password") finds both, and aria-describedby points to the hint.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
);
}offline event switches the hook to false. In Edge, Playwright’s offline mode does the same.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>
);
}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
StrictModeis 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/useMemofor those props
an input lags or does not type
- Cause
- the input value is updated inside
startTransition - What to do
- a plain
setStatefor the input, the heavy part viauseDeferredValue
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
useis 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.