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. Package Version License What it offers usehooks-ts 3.1.1 MIT ~60 TypeScript Hooks, individually tree-shakeable react-use 17.6.1 Unlicense ~100 Hooks, the largest collection, lightly maintained ahooks 3.10.0 MIT Alibaba's suite, strong request and lifecycle Hooks @uidotdev/usehooks 2.4.1 MIT Small curated set, copy-paste friendly source Table 2.16: Open-source custom Hook collections, versions checked September 2026 Install any of them the usual way, for example npm install usehooks-ts. Read the source first: a Hook is usually twenty lines, and owning them costs less than a dependency upgraded in step with React.

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>

Listing 2.78: One Hook, two independent pieces of state 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. Tip: Keep custom Hooks tied to a concrete use case — useChatRoom, useCounter, useFetch. Generic lifecycle wrappers such as useMount or useUpdateEffect hide dependencies from the linter and buy nothing.

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

}

Listing 2.79: Breaking, then fixing, the call order Put the condition inside the Hook, as the fixed version does, rather than around it. The figure below shows what the broken version does to the slot list once user.isAdmin turns false.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. Under the hood: React stores the slots as a linked list on the fiber (memoizedState), advancing a cursor per Hook call. That is why "Rendered fewer hooks than expected" names no Hook: all React knows is that the cursor stopped early.

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;

}

Listing 2.80: A value that lags one render behind A counter rendering now: {count}, before: {String(previous)} therefore shows 4 and 3 after the fourth click. Writing to a ref schedules no render, so the Hook costs nothing extra; on the first render it returns undefined, which is why the component prints String(previous) rather than assuming a number.The pattern works for props, state or any computed value, with one trap: it updates once per render, not once per change, so a re-render caused by something else leaves previous equal to value. When what you actually want is "reset state when a prop changes", prefer Section 2.3.7's technique — a key on the child, or a comparison held in state — which stays correct under concurrent rendering.

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;

}

Listing 2.81: A fetching Hook with cancellation and error handling A caller destructures the three fields and branches on loading, then error, then renders data. Three details carry the weight. The cleanup aborts the request, so a component that unmounts mid-flight neither leaks nor sets state on a dead fiber; since an aborted fetch rejects with an AbortError, that name is filtered out rather than shown to the user. Abort also settles out-of-order responses: when url changes React runs the previous cleanup first, so a stale request can never win the race. And the three fields are set together in one object, making an impossible state such as "loading and error at once" unrepresentable.The headers object is serialized in the dependency array because a fresh object literal each render would restart the request forever. That workaround hints at the Hook's ceiling: no cache, no deduplication, no retry, no refetch on focus. Once several components want the same URL, move to TanStack Query 5.103.1 (npm install @tanstack/react-query), or let a framework's router load the data. React 19 also lets you hand a cached promise to the use Hook (Section 2.8.6) and let Suspense show the loading 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;

}

Listing 2.82: Subscribing to the viewport size A component writes const { width } = useWindowSize(); and branches on it, rendering a tab bar below 700 pixels and a sidebar above.Passing readSize itself to useState — not readSize() — makes it a lazy initializer (Section 2.3.5), so the first paint already has real numbers and there is no flash of 0 x 0. The listener is registered once under an empty dependency array and removed in the cleanup, so mounting the Hook in twenty components costs twenty listeners, not twenty per render.Two cautions. window does not exist while a server renders, so in Next.js guard the initializer or read the value through useSyncExternalStore, which takes a separate server snapshot. And resize fires on every pixel of a drag: if the render is expensive, feed the value through useDebounce (Section 2.10.8) or switch to window.matchMedia('(min-width: 700px)'), which fires only when the answer changes. When the decision is purely visual, no Hook belongs in it at all — a CSS media query costs zero renders.

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]);

}

Listing 2.83: Closing a panel when the pointer lands elsewhere A dropdown puts a ref on its panel, keeps an open flag in state, and passes useCallback(() => setOpen(false), []) as onOutside.pointerdown is deliberate: a click listener fires only after release, by which time a drag that began inside the panel and ended outside it would close the panel under the user's finger, and pointerdown covers touch and pen too. onOutside is memoized at the call site because it sits in the dependency array — an inline arrow would detach and reattach both listeners on every render; useEffectEvent (Section 2.4.7) removes that requirement.One blind spot: contains walks the DOM tree, and a panel rendered through a portal (Section 2.11.7) is not a DOM descendant of the component that owns it, so every click on it looks outside. Keep a second ref to the portal node and test both. Before writing any of this, check whether the platform already does it: an auto popover (<div popover>, Baseline since April 2024) and <dialog> both light-dismiss on an outside click and on Escape, with no listener and no ref at all.

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;

}

Listing 2.84: Loading a third-party script on demand Callers branch on the status: const status = useScript(SDK_URL);, render a spinner while it is 'loading', and touch window.PaymentSDK only once it is 'ready'.Note what the cleanup does not do. Removing the <script> element would undo nothing the script already did — globals, listeners and timers survive — while the next mount would download and execute it again. So the tag stays, the listeners go, and data-status lets a later mount skip straight to the final state.For code you control, await import('./chart.js') is better in every way: a module, typed, splittable by the bundler. And in React 19 you can render <script async src={url} /> inside the component that needs it; React hoists it and loads it once even when several components render the same tag.

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;

}

Listing 2.85: Delaying a value until the typing stops The component keeps the input controlled and fully responsive — const [text, setText] = useState('') — and fetches from the slow copy: const query = useDebounce(text, 300);, with query as the request Effect's only dependency. Each new character re-runs the debouncing Effect, whose cleanup clears the timer the previous character started, so the update lands 300 ms after the last change and not before.Debouncing waits for silence; throttling lets one call through per interval and suits events that never stop, such as scroll. For both, plus a cancel and flush API, use-debounce 10.1.1 (MIT, github.com/xnimorz/use-debounce (https://github.com/xnimorz/use-debounce)) is the standard package. But first check whether the delay is really about the network: when the cost is rendering rather than fetching, useDeferredValue (Section 2.8.4) is better, because it yields to the browser instead of guessing at a number of milliseconds.

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];

}

Listing 2.86: State that survives a reload Call it exactly like useState: const [theme, setTheme] = useLocalStorage('theme', 'light');. The write happens inside the setter, not in an Effect, which avoids a pointless write on every mount.Three details are easy to get wrong. localStorage stores strings only, so anything else needs the JSON round trip — and a value written by an older version of your app may no longer parse, which is what the first catch is for. Access can throw outright: Safari's private mode and a full quota both reject writes, and localStorage does not exist during server rendering, so a Next.js page must read it in an Effect or guard on typeof window. Finally, the Hook does not notice changes made in another tab; if that matters, listen for the storage event or wire the key through useSyncExternalStore (Section 2.8.8).

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;

}

Listing 2.87: Labeling a Hook in React DevTools DevTools now shows OnlineStatus: "Online" next to the call — the use prefix is stripped — instead of the raw store snapshot. The optional second argument is a formatting function DevTools calls only when someone inspects the component, so an expensive conversion costs nothing in normal rendering: useDebugValue(date, d => d.toDateString()). Passing date.toDateString() directly would run every render.Use it sparingly. React's own advice is to reserve it for Hooks in shared libraries whose internal data is hard to read; a label on every Hook turns the inspector into noise. And it pays off only once the tooling is installed: React DevTools ships as an extension for Chrome, Firefox and Edge, adding the Components and Profiler tabs of Section 2.1.8. Press F12, select the component, and the Hooks panel lists every Hook it called, labels included.