Skip to content

Hooks

Hooks are functions that let a component hook into React features — state, lifecycle, context — from inside a plain function. useState and useEffect are the two you use constantly; the rest of the built-ins solve narrower problems, and custom hooks let you package any combination for reuse.

There are exactly two, and both are enforced by eslint-plugin-react-hooks.

Never inside conditions, loops, nested functions, or after an early return.

// All wrong
if (isLoggedIn) {
const [name, setName] = useState('');
}
for (const item of items) {
useEffect(() => {}, []);
}
if (!user) return null;
const [x, setX] = useState(0); // unreachable on some renders
// Right: hooks first, unconditionally; put the condition inside
const [name, setName] = useState('');
useEffect(() => {
if (!isLoggedIn) return;
}, [isLoggedIn]);
if (!user) return null;

Why. React does not know the names of your state variables. It stores hook data per component instance in an ordered list and walks that list in the same order on every render, matching call #1 to slot #1, call #2 to slot #2, and so on. There is no key beyond position.

Render 1: useState('') useState(0) useEffect(…)
slot 0 slot 1 slot 2
Render 2 with a skipped hook:
useState(0) useEffect(…)
slot 0 ✗ slot 1 ✗ ← everything shifted; state is now wrong

Skipping a hook on one render silently rebinds every subsequent hook to the wrong slot. Keeping calls unconditional keeps the order stable.

Legal callers: a component function, or another hook. Not regular functions, not event handlers, not class methods, not callbacks.

// Wrong
function handleClick() {
const [x, setX] = useState(0); // no component is rendering
}
// Right
function useSomething() { // a custom hook — name begins with "use"
const [x, setX] = useState(0);
return x;
}

The use prefix is not decoration: the linter uses it to decide whether a function is allowed to call hooks.

useRef(initial) returns a mutable box, { current: initial }, that survives every render and does not trigger a re-render when changed.

Two distinct uses.

Attach the ref to a JSX element and React sets ref.current to the DOM node after commit (and back to null on unmount).

SearchBox.jsx
import { useRef, useEffect } from 'react';
function SearchBox() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
}, []);
return <input ref={inputRef} type="search" />;
}

This is the sanctioned way to do the small number of things that are genuinely imperative: focus, text selection, scrolling, measuring size, and driving media or canvas APIs.

inputRef.current.select();
listRef.current.scrollIntoView({ behavior: 'smooth' });
const { width, height } = boxRef.current.getBoundingClientRect();
videoRef.current.play();

Do not use refs to change what React renders (setting textContent, adding classes, hiding nodes). React will overwrite you on the next render, and you have reintroduced the imperative model React exists to remove.

A callback ref gives you the node as it attaches, which is useful when the element is conditional or in a list:

<div ref={(node) => { if (node) observer.observe(node); }} />

Mutable values that must not cause renders

Section titled “Mutable values that must not cause renders”

Anything the render output does not depend on: timer ids, previous values, instance counters, third-party objects, “did this already happen” flags.

const timeoutRef = useRef(null);
function startCountdown() {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(onExpire, 5000);
}
useEffect(() => () => clearTimeout(timeoutRef.current), []);

The decision rule: if changing it should update the screen, it is state; if not, it is a ref.

Do not read or write a ref during rendering — that makes the render impure and its output depend on hidden history. Refs belong in effects and event handlers.

Both cache something between renders, keyed by a dependency array with the same comparison rules as useEffect.

const value = useMemo(() => expensiveCompute(a, b), [a, b]); // caches a VALUE
const fn = useCallback(() => doSomething(a), [a]); // caches a FUNCTION

useCallback(fn, deps) is exactly useMemo(() => fn, deps) with nicer syntax.

Only two situations:

  1. A genuinely expensive computation — sorting or filtering thousands of items, parsing, heavy formatting. “Expensive” means milliseconds you can measure in the Profiler, not a .map() over ten rows.

    const sorted = useMemo(
    () => hugeList.slice().sort((a, b) => a.score - b.score),
    [hugeList]
    );
  2. Referential stability — keeping an object or function identity the same across renders, because something downstream compares by identity: a memo()-wrapped child, a useEffect dependency, or another useMemo.

    const handleSelect = useCallback((id) => setSelectedId(id), []);
    return <ExpensiveList items={items} onSelect={handleSelect} />; // ExpensiveList is memo()'d

Outside those cases they cost more than they save: extra allocations, a dependency array to keep correct, and noisier code. Wrapping a callback in useCallback while the child is not memoized accomplishes nothing at all.

useReducer moves state transitions out of the component and into a pure function.

const [state, dispatch] = useReducer(reducer, initialState);

reducer(state, action) returns the next state. dispatch(action) sends an action to it. Both dispatch and the reducer are stable — dispatch never changes identity, so it never needs to be a dependency.

Cart.jsx
import { useReducer } from 'react';
const initialState = { items: [], discount: 0 };
function cartReducer(state, action) {
switch (action.type) {
case 'added':
return { ...state, items: [...state.items, action.item] };
case 'removed':
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
case 'quantity_changed':
return {
...state,
items: state.items.map((i) =>
i.id === action.id ? { ...i, quantity: action.quantity } : i
),
};
case 'discount_applied':
return { ...state, discount: action.percent };
case 'cleared':
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<>
<button onClick={() => dispatch({ type: 'added', item: newItem })}>Add</button>
<button onClick={() => dispatch({ type: 'cleared' })}>Clear</button>
<p>{state.items.length} items</p>
</>
);
}

There is also a third argument for lazy initialization: useReducer(reducer, initialArg, init) calls init(initialArg) once to produce the initial state.

  • Several state values change together in fixed combinations (loading + data + error).
  • The same update logic is triggered from many handlers.
  • The next state depends on the current state in non-trivial ways.
  • You have “state machine” behaviour — a set of legal transitions you want in one readable place.
  • You want to test transitions: a reducer is a pure function, testable without React.

Stick with useState for independent, simple values. A boolean toggle does not need a reducer.

The reducer must be pure: no fetching, no mutation of state, no Math.random(). Under Strict Mode React calls reducers twice in development to catch impurity.

Context passes a value to every component below a provider, without threading props through the layers in between (prop drilling).

Three steps.

ThemeContext.js
import { createContext } from 'react';
export const ThemeContext = createContext('light'); // argument is the default
App.jsx
import { useState } from 'react';
import { ThemeContext } from './ThemeContext';
function App() {
const [theme, setTheme] = useState('dark');
return (
<ThemeContext.Provider value={theme}>
<Layout />
</ThemeContext.Provider>
);
}
Button.jsx
import { useContext } from 'react';
import { ThemeContext } from './ThemeContext';
function Button({ children }) {
const theme = useContext(ThemeContext);
return <button className={`btn-${theme}`}>{children}</button>;
}

useContext reads the value from the nearest matching provider above in the tree. The default passed to createContext is used only when there is no provider at all — which usually means a bug, so a common idiom is a null default plus a guard hook:

AuthContext.jsx
const AuthContext = createContext(null);
export function useAuth() {
const ctx = useContext(AuthContext);
if (ctx === null) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
}

Providers nest: an inner provider overrides an outer one for its subtree. That is how per-section themes work.

Every consumer re-renders when the provider’s value changes, identity-compared. Passing an object literal means a new value on every render of the provider, so every consumer re-renders every time:

// New object each render → all consumers re-render
<AuthContext.Provider value={{ user, login, logout }}>

Memoize the value, and keep the functions stable:

const login = useCallback((creds) => { … }, []);
const logout = useCallback(() => { … }, []);
const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

Also split contexts that change at different rates — a rapidly-changing value and a stable set of actions should be two contexts, so consumers of the actions do not re-render on every change of the value.

A custom hook is a function whose name starts with use and which calls other hooks. It exists to reuse stateful logic, not UI.

Crucially, each call gets its own independent state — the hook is a recipe, not a shared instance.

useToggle.js
import { useState, useCallback } from 'react';
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle, setOn];
}
const [isOpen, toggleOpen] = useToggle();
const [isMuted, toggleMuted] = useToggle(true); // completely separate state

Something with cleanup:

useEventListener.js
import { useEffect, useRef } from 'react';
export function useEventListener(target, type, handler) {
const savedHandler = useRef(handler);
useEffect(() => {
savedHandler.current = handler; // always call the latest version
}, [handler]);
useEffect(() => {
const el = target ?? window;
const listener = (e) => savedHandler.current(e);
el.addEventListener(type, listener);
return () => el.removeEventListener(type, listener);
}, [target, type]);
}

The ref indirection is a common custom-hook idiom: it lets the effect stay attached (deps unchanged) while still calling the newest callback, avoiding a resubscribe on every render.

A debounced value:

useDebouncedValue.js
import { useState, useEffect } from 'react';
export function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // a new keystroke cancels the pending update
}, [value, delay]);
return debounced;
}
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 400);
useEffect(() => {
if (debouncedQuery) search(debouncedQuery);
}, [debouncedQuery]);
  • Name it use… — required for the lint rules to work.
  • Return whatever fits: an array when the names are up to the caller (like useState), an object when there are several named things.
  • Keep it focused. A hook that does five unrelated things is a module, not a hook.
  • Extract logic, not just lines. A custom hook wrapping a single useState earns nothing.
  • Hooks compose: custom hooks calling custom hooks is normal and is how libraries are built.

You will meet these less often; know they exist and what problem they solve.

Hook Purpose
useLayoutEffect Like useEffect but synchronous before paint — for measuring and adjusting layout without flicker.
useId Generates a stable unique id, consistent between server and client. For htmlFor/aria-describedby, not for list keys.
useTransition Marks a state update as non-urgent so typing stays responsive; gives you an isPending flag.
useDeferredValue Renders a lagging copy of a value, so expensive subtrees don’t block input.
useSyncExternalStore Subscribes safely to a store outside React. What state libraries use internally.
useImperativeHandle Customizes what a parent’s ref exposes — a narrow API instead of the raw DOM node.
useDebugValue Labels a custom hook in React DevTools.

React 19 adds hooks aimed at forms and async work: use (read a promise or context, and it may be called conditionally), useActionState, useOptimistic, and useFormStatus from react-dom. They are most useful with a framework that supports server actions.

  • Hooks must be called unconditionally at the top level, because React matches them to storage by call order.
  • Hooks may only be called from components or other hooks; the use prefix is what makes a function a hook.
  • useRef is a render-surviving box that does not cause re-renders — for DOM nodes and for values the UI doesn’t display.
  • useMemo/useCallback pay off only for measurably expensive work or for referential stability that something downstream depends on.
  • useReducer centralizes complex or multi-value transitions in a pure, testable function; dispatch is stable.
  • useContext removes prop drilling but re-renders every consumer when the provider value’s identity changes — memoize it and split contexts by change rate.
  • Custom hooks reuse stateful logic; every call site gets its own state.