Custom hooks and the rules of hooks
A custom hook shares stateful logic, not state. Why React identifies hooks by call order, and what breaks when a hook is called conditionally.
After this lesson you can
- Extract repeated stateful logic into a custom hook
- Explain why two components using the same hook do not share state
- Explain the rules of hooks in terms of call order, and what happens when they are broken
When two components repeat the same useState + useEffect dance — track
the window width, read and write localStorage, debounce a value — move
it into a function whose name starts with use:
function useOnlineStatus() {
const [online, setOnline] = useState(true);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener("online", on);
window.addEventListener("offline", off);
return () => {
window.removeEventListener("online", on);
window.removeEventListener("offline", off);
};
}, []);
return online;
}
That is all a custom hook is: a function that calls other hooks. The use
prefix is a convention that tells readers — and the lint rules — that the
function follows the rules of hooks. A helper that calls no hooks should
not start with use.
Hooks share logic, not state
Calling a custom hook is the same as pasting its body into the component.
Each component that calls useCounter() gets its own useState inside
it, so their values are independent. To share one value between
components, lift the state up or put it in context.
How React knows which state is which
useState(0) carries no name. React keeps, for each component instance,
a list of hook slots, and assigns them by the order of the calls: the
first hook call during a render gets slot 0, the second slot 1, and so on.
The model below is that mechanism in a dozen lines:
// One slot list per component instance; a cursor walks it on each render.function createInstance(component) { const slots = []; let cursor = 0; const useState = (initial) => { const i = cursor++; if (!(i in slots)) slots[i] = initial; const set = (v) => { slots[i] = v; }; return [slots[i], set]; }; return { render(props) { cursor = 0; return component(props, { useState }); }, };} // A custom hook: just a function that calls hooks.function useCounter(hooks, start) { const [count, setCount] = hooks.useState(start); return { count, increment: () => setCount(count + 1) };} function Counter(props, hooks) { return useCounter(hooks, props.start);} export function run() { const a = createInstance(Counter); const b = createInstance(Counter); a.render({ start: 0 }).increment(); a.render({ start: 0 }).increment(); b.render({ start: 100 }).increment(); return { a: a.render({ start: 0 }).count, b: b.render({ start: 100 }).count };}Both counters called the same hook, yet each component instance had its own slot list, so their counts stayed separate.
The rules of hooks
Because slots are matched by position, the sequence of hook calls must be identical on every render of a component:
- Call hooks only at the top level — never inside a condition, loop,
nested function, or after an early
return. - Call hooks only from components and other hooks, not from ordinary functions or event handlers.
If a condition skips a hook, every hook after it reads the wrong slot:
function createInstance(component) { const slots = []; let cursor = 0; const useState = (initial) => { const i = cursor++; if (!(i in slots)) slots[i] = initial; return slots[i]; }; return { render: (props) => { cursor = 0; return component(props, useState); } };} function Profile({ showName }, useState) { let name = null; if (showName) name = useState("Ada"); // breaks the rules const age = useState(36); const unused = showName ? null : useState("filler"); // keeps the count equal return { name, age };} export function run() { const p = createInstance(Profile); return [p.render({ showName: true }), p.render({ showName: false })];}The second render skipped the name hook, so age read slot 0 and got
the name. (In development React also logs a warning that it detected a
change in the order of hooks.) When the number of hooks changes between
renders, React throws
instead ("Rendered more hooks than during the previous render" or
"Rendered fewer hooks than expected"). The fix is to always call the hook
and make the use of its value conditional. React 19's use API is the
one exception: it reads a promise or context and may be called inside a
condition.
Common mistakes
- Expecting a custom hook to share state between components. Each call has its own slots, as the counters in the first example show.
- Calling a hook inside an
ifor after an early return. Later hooks read the wrong slots, or React throws when the count changes. - Naming a plain helper
useSomething. It misleads readers and the linter, and stops you calling it conditionally for no reason. - Returning a new object or function from a hook on every render when callers pass it to memoised children or effect dependencies; memoise what the hook returns.
What interviewers ask
- Why can hooks not be called conditionally? — React matches each hook call to its stored state by call order; skipping a call shifts every later hook onto the wrong state.
- Do two components using the same custom hook share state? — No; each call has its own state. Share by lifting state up or using context.
- When would you write a custom hook? — When stateful logic (state plus effects) is repeated across components or makes one component hard to read.
Exercise · Pro
Which state each hook receives
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade