AcademyReact
Writing Custom Hooks
A custom hook is a plain function that calls other hooks. It shares logic, not state — two components using it get two independent copies.
5 min readUpdated 3 exercises
There is no special mechanism here
A custom hook is a JavaScript function, full stop. It is not registered anywhere, does not need a special export, and React itself has no idea a given function is "a hook" versus any other function. The only thing marking it as one is a naming convention — starting the name with use — and that convention exists purely so the linter (eslint-plugin-react-hooks) knows which functions to apply the Rules of Hooks to.
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
}useWindowWidth is not doing anything a regular function could not — it calls useState and useEffect, exactly as a component would, and returns a value. Any component can now call useWindowWidth() to get a live-updating width, without repeating the listener setup and cleanup.
What gets shared is the logic, not the state
This is the single most common misunderstanding, and it produces real bugs when assumed wrong: calling the same custom hook from two different components does not connect them to shared state. Each call to useWindowWidth() runs its own independent useState call and its own independent useEffect — two separate listeners, two separate width variables, coincidentally kept in sync only because they both listen to the same browser event.
function HeaderA() {
const width = useWindowWidth(); // its own state
return <p>A: {width}</p>;
}
function HeaderB() {
const width = useWindowWidth(); // a DIFFERENT, independent state
return <p>B: {width}</p>;
}Both values happen to track the same resize events, so they look synchronized — but they are two unrelated useState calls, not one shared cell. If you actually need two components reading and writing the same piece of state, a custom hook does not get you there; that needs the state lifted to a common ancestor, or held in Context. What a custom hook shares is the recipe for producing and managing state, not a slot of state itself.
The Rules of Hooks exist because of how state gets matched to calls
Two rules: call hooks only at the top level (never inside a condition, loop, or nested function), and call them only from React function components or from other hooks. Both exist for the same underlying reason — React does not match a piece of state to a hook by name or by argument, it matches by call order.
function Component() {
const [a, setA] = useState(0); // 1st call → React's 1st state slot
const [b, setB] = useState(0); // 2nd call → React's 2nd state slot
useEffect(() => { /* ... */ }); // 3rd call → React's 3rd slot
}React keeps a list, per component instance, of "the state for the Nth hook call this component made." As long as every render calls the same hooks in the same order, slot 2 is always b. Wrap a useState call in a condition and skip it on some renders, and every hook call after it shifts by one slot on the renders where it was skipped — React now hands b's old value to whatever the third call happens to be that render. This is not a lint nitpick; it silently corrupts state, which is why the rule is enforced at the top level of both components and custom hooks alike, and why a custom hook calling other hooks conditionally breaks the same way a component would.
Extracting one: a duplicated fetch becomes useFetch
// Before: the same shape, copied into two components
function UserProfile({ id }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUser(id).then((data) => {
if (!cancelled) { setUser(data); setLoading(false); }
});
return () => { cancelled = true; };
}, [id]);
// ...
}
function UserAvatar({ id }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUser(id).then((data) => {
if (!cancelled) { setUser(data); setLoading(false); }
});
return () => { cancelled = true; };
}, [id]);
// ...
}// After: the shared shape lives once
function useUser(id) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUser(id).then((data) => {
if (!cancelled) { setUser(data); setLoading(false); }
});
return () => { cancelled = true; };
}, [id]);
return { user, loading };
}
function UserProfile({ id }) {
const { user, loading } = useUser(id);
// ...
}
function UserAvatar({ id }) {
const { user, loading } = useUser(id); // independent state, same recipe
// ...
}The stale-closure guard (cancelled) and the loading/error bookkeeping now exist in exactly one place. Fix a bug in useUser once, and both components benefit — without either one sharing a byte of actual state with the other.
When it earns its place
The signal to extract a custom hook is the same signal for extracting any function: the same stateful shape appearing in two or more components, or one component visibly doing several unrelated jobs that would each read more clearly named and isolated. Extracting a hook used by exactly one caller, purely to look organized, adds a layer of indirection — a reader now has to jump to a second file to see state that would have been just as clear inline. The hook should pay for the jump with actual reuse or actual clarity, not exist because "custom hooks" felt like the more idiomatic thing to write.
Now practice it
2 challenges · 1 quizReading this page is the cheap half. These are the exercises that make you use it.
- useDebounce HookProA custom hook with real internal state and cleanup — timers to clear, not just a wrapped useState.ChallengeSolve · MediumChallengeSolve · Medium
- useAsync HookProEncapsulates exactly the loading/error/cancellation shape this page extracts into useUser, generalized.ChallengeSolve · MediumChallengeSolve · Medium
- Performance · Profiling & OptimizationHook composition and call-order questions at the depth where "it depends which render" is the actual answer.QuizTake · HardQuizTake · Hard
Read next
Dependencies and Cleanup in useEffect
An Effect synchronizes with something outside React. The dependency array says what it reads, not when to run — get that backwards and you get stale values.
4 min
Refs and Imperative DOM Access
useRef is a mutable box that survives re-renders without causing them — the escape hatch for the DOM and for values that are not part of render.
3 min