MENU
Custom Hooks
A custom Hook is an ordinary JavaScript function whose name begins with use and which is allowed to call other Hooks. That is the whole definition â no registration step, no base class, no special import. Move a useState and a useEffect out of a component into a function named useSomething and React treats it exactly as it treats the built-in Hooks.What you gain is a way to share stateful logic. The distinction matters: two components that call useWindowSize() do not share one width variable, each gets its own state, Effect and cleanup. Only the logic is written once. Sharing the value is what Context (Section 2.5) and external stores are for.Hooks also replace the two patterns React used before 2019 for this job, higher-order components and render props (Section 2.11), which wrap your component in layers that clutter the tree and fight over prop names. A Hook adds no element at all. The eight Hooks below cover most projects, and writing them yourself usually beats a dependency â but know the ecosystem.2.10.1 Extracting a Custom Hook
Extraction starts from duplication. Two components each hold a number and bump it by a fixed step; their JSX differs, the lines above it do not. Cut those lines into a function, turn the values that differ into parameters, name it useCounter, and return what the components need. Because the function calls useState the use prefix is mandatory: it is how React's linter, and a human reader, know the call has to obey the Rules of Hooks.A Hook may return anything: an array when the caller will rename the entries (const [value, setValue] = useThing(), the convention useState established), an object when there are more than two. The demo calls useCounter twice; a script clicks the apples button three times and the oranges button twice, 100 ms apart.<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",
"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }</script>
<script type="text/babel" data-type="module">
import React, { useState, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
function useCounter(start, step) {
const [count, setCount] = useState(start);
const increment = useCallback(() => setCount(c => c + step), [step]);
return { count, increment };
}
function Counter({ label, start, step }) {
const { count, increment } = useCounter(start, step);
return <p style={{ font: '16px system-ui' }}>{label}: <b>{count}</b>{' '}
<button onClick={increment}>+{step}</button></p>;
}
createRoot(document.getElementById('root')).render(
<><Counter label="Apples" start={0} step={1} />
<Counter label="Oranges" start={100} step={5} /></>);
let t = 150;
for (const i of [0, 0, 0, 1, 1]) {
setTimeout(() => document.querySelectorAll('button')[i].click(), t);
t += 100;
}
</script>
Figure 2.37: Browser output of Listing 2.78Apples reaches 3 and oranges 110: neither call site sees the other's
count. A custom Hook shares logic, not state. For one genuinely shared
number, put it in a Context provider or an external store and let the
Hook read from there â that is what useSyncExternalStore (Section 2.8.8)
exists for.2.10.2 The Rules of Hooks
React identifies a Hook by when it is called, not by a name or key you pass it. Each component instance keeps an ordered list of Hook slots: the render's first useState is slot 1, the next Hook call slot 2, and so on. On the next render React walks the same list in the same order and hands each call its slot back. Two rules follow.Rule 1 â call Hooks only at the top level. Never inside a condition, a loop, a try/catch/finally block, an event handler, or after an early return. Anything that lets the number or order of calls vary between renders shifts every later Hook one slot, and a state value reappears as somebody else's.function Profile({ user }) {
if (!user) return null; // early return: the count can change
const [draft, setDraft] = useState('');
if (user.isAdmin) useEffect(logAdminVisit); // conditional Hook: never do this
}
function ProfileFixed({ user }) {
const [draft, setDraft] = useState(''); // always slot 1
useEffect(() => { if (user?.isAdmin) logAdminVisit(); }, [user]);
if (!user) return null; // return after every Hook call
}
Figure 2.38: One skipped Hook call shifts every later slot on the
next renderRule 2 â call Hooks only from React functions. A
Hook may be called from a function component's body or from another
custom Hook, and nowhere else: not from a plain helper, a class method,
or a setTimeout callback. A custom Hook may call Hooks precisely because
it only ever runs while a component renders.Both rules are enforced mechanically by eslint-plugin-react-hooks
(7.1.1, MIT, github.com/facebook/react
(https://github.com/facebook/react)), added with npm install -D
eslint-plugin-react-hooks. It treats any function named use plus a
capital letter as a Hook and any PascalCase function as a component.
Since the React Compiler reached 1.0 its recommended preset also ships
compiler-powered rules â exhaustive dependencies, purity, correct ref
use â whether or not you enable the compiler.2.10.3 usePrevious
Comparing a value with what it was on the previous render is a recurring need: animating only when a number really changed, logging transitions, firing a request when an id moves on. A ref plus an Effect gives you that in six lines, because the Effect runs after the browser has painted: during a render the ref still holds the value the Effect stored last time.import { useRef, useEffect } from 'react';
export function usePrevious(value) {
const ref = useRef(undefined);
useEffect(() => { ref.current = value; }); // no dependency array: after every render
return ref.current;
}
2.10.4 useFetch
Section 2.4.4 listed the four things a hand-written data Effect has to get right: a loading flag, an error path, cancellation on unmount, and protection against out-of-order responses. Every fetching component needs all four, which makes this the most rewarding Hook here to extract.import { useEffect, useState } from 'react';
export function useFetch(url, { headers } = {}) {
const [state, setState] = useState({ data: null, error: null, loading: true });
useEffect(() => {
const controller = new AbortController();
setState({ data: null, error: null, loading: true });
fetch(url, { headers, signal: controller.signal })
.then(res => res.ok ? res.json()
: Promise.reject(new Error(`HTTP ${res.status} ${res.statusText}`)))
.then(data => setState({ data, error: null, loading: false }))
.catch(error => error.name === 'AbortError' ? null
: setState({ data: null, error, loading: false }));
return () => controller.abort();
}, [url, JSON.stringify(headers)]);
return state;
}
2.10.5 useWindowSize
Layout decisions CSS cannot make â how many rows a virtualized list should render, whether to mount a drawer or a sidebar â need the viewport size as a JavaScript value. Subscribe once in a Hook and every component that asks gets a number that stays current.import { useState, useEffect } from 'react';
const readSize = () => ({ width: window.innerWidth, height: window.innerHeight });
export function useWindowSize() {
const [size, setSize] = useState(readSize); // lazy: real numbers on the first render
useEffect(() => {
const onResize = () => setSize(readSize());
window.addEventListener('resize', onResize);
onResize(); // catch a resize during hydration
return () => window.removeEventListener('resize', onResize);
}, []);
return size;
}
2.10.6 useOutsideAlerter
Dropdowns, popovers and date pickers share one behavior: a click anywhere else closes them. The Hook takes a ref to the element that counts as "inside" and a callback for everything else.import { useEffect } from 'react';
export function useOutsideAlerter(ref, onOutside) {
useEffect(() => {
function handle(event) {
if (event.key === 'Escape') return onOutside(event);
if (ref.current && !ref.current.contains(event.target)) onOutside(event);
}
const types = ['pointerdown', 'keydown'];
types.forEach(t => document.addEventListener(t, handle));
return () => types.forEach(t => document.removeEventListener(t, handle));
}, [ref, onOutside]);
}
2.10.7 useScript
Some third-party code still arrives as a classic <script> tag: a payment widget, a maps SDK, an analytics snippet. Loading it from index.html makes every visitor pay for it. A Hook loads it from the component that needs it and reports when it is usable.import { useEffect, useState } from 'react';
export function useScript(src) {
const [status, setStatus] = useState('loading');
useEffect(() => {
const found = document.querySelector(`script[src="${src}"]`);
if (found) setStatus(found.dataset.status || 'loading');
const el = found || document.body.appendChild(
Object.assign(document.createElement('script'), { src, async: true }));
const done = e => {
el.dataset.status = e.type === 'load' ? 'ready' : 'error';
setStatus(el.dataset.status);
};
const types = ['load', 'error'];
types.forEach(t => el.addEventListener(t, done));
return () => types.forEach(t => el.removeEventListener(t, done));
}, [src]);
return status;
}
2.10.8 useDebounce
A search box that queries the server on every keystroke sends ten requests for a six-letter word. Debouncing returns a copy of the value that changes only once the user has paused, and the Hook fits in nine lines because setTimeout and Effect cleanup do all the work.import { useState, useEffect } from 'react';
export function useDebounce(value, delay = 400) {
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;
}
2.10.9 useLocalStorage
A theme choice, a collapsed sidebar or a draft message should survive a reload. This Hook keeps useState's API â a value and a setter that accepts an updater function â and mirrors every change into localStorage.import { useState, useCallback } from 'react';
export function useLocalStorage(key, initial) {
const [value, setValue] = useState(() => {
try {
const raw = localStorage.getItem(key);
return raw === null ? initial : JSON.parse(raw);
} catch { return initial; } // private mode, disabled storage, bad JSON
});
const set = useCallback(next => setValue(prev => {
const resolved = next instanceof Function ? next(prev) : next;
try { localStorage.setItem(key, JSON.stringify(resolved)); } catch {}
return resolved;
}), [key]);
return [value, set];
}
2.10.10 useDebugValue
React DevTools lists a component's Hooks in call order, labeled with their internal state: State: true tells you nothing about which of three booleans you are looking at. useDebugValue(value, format?) attaches a readable label to a custom Hook. It returns nothing and affects nothing but the inspector.import { useDebugValue, useSyncExternalStore } from 'react';
export function useOnlineStatus() {
const isOnline = useSyncExternalStore(subscribe, () => navigator.onLine, () => true);
useDebugValue(isOnline ? 'Online' : 'Offline');
return isOnline;
}