React120 min total · 17 parts
React Fundamentals: Components, Hooks, and the Virtual DOM
Part 13 of 17 · ~4 min
Custom Hooks
The race-condition guard from chapter 7 is correct, subtle, and currently living inside one component. The moment a second component needs candidate detail — a print view, a comparison panel — somebody will write the fetch again and forget the guard.
A custom hook is a function that calls other hooks. That is the entire definition. The use prefix is a convention, not syntax, but it is the convention React's linter uses to identify a hook and enforce the rules on it, so it is not optional in practice.
function useCandidateDetail(candidateId) {
const [detail, setDetail] = useState(null);
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
useEffect(() => {
if (!candidateId) return; // nothing selected
const controller = new AbortController();
setStatus("loading");
fetch(`/api/candidates/${candidateId}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
})
.then((data) => { setDetail(data); setStatus("success"); })
.catch((err) => {
if (err.name === "AbortError") return; // superseded, not failed
setError(err);
setStatus("error");
});
return () => controller.abort();
}, [candidateId]);
return { detail, status, error };
}
And the drawer, which now contains no fetching at all:
function ReviewDrawer({ candidateId, onClose }) {
const { detail, status, error } = useCandidateDetail(candidateId);
if (status === "loading") return <Drawer onClose={onClose}><Spinner /></Drawer>;
if (status === "error") return <Drawer onClose={onClose}><ErrorNote error={error} /></Drawer>;
return (
<Drawer title={detail.name} onClose={onClose}>
<SubmissionSummary candidate={detail} />
<ReviewForm key={candidateId} candidateId={candidateId} />
</Drawer>
);
}
This is where React's reusability actually comes from, and it is worth being precise about what got reused. Not UI — logic. State, an effect, a cleanup and an error path, packaged behind a function call, exactly the way useState and useEffect are themselves packaged. Any component can now have correctly-cancelled candidate detail in one line, and nobody has to remember the guard.
One thing custom hooks do not do: share state between components. Two components calling useCandidateDetail("c_8814") get two independent pieces of state and two independent fetches. A hook is a reusable recipe, not a shared instance. Sharing the value takes Context, or a data-fetching library with a cache, or lifting the state to a common parent. Expecting a hook to deduplicate by itself is a common and expensive misunderstanding.
The dashboard gets a second hook almost for free — persisting the reviewer's filters between sessions, so reopening the tab restores what they were looking at:
function usePersistentReducer(key, reducer, initial) {
const [state, dispatch] = useReducer(reducer, initial, (fallback) => {
try {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : fallback;
} catch {
return fallback; // private mode, quota, corrupt JSON
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, dispatch];
}
const [filters, dispatch] = usePersistentReducer("filters", filtersReducer, INITIAL_FILTERS);
The lazy initialiser matters there: passing a function as the third argument means localStorage is read once, on mount, rather than on every render.
The Rules of Hooks
1. Every hook call has to sit at the top level of a component or another hook. Not nested inside a condition, a loop, a nested function, or placed after a conditional return.
2. A hook may only be called from inside a React function component, or from inside another custom hook. Never from a plain utility function, a class, or an event handler.
Both rules trace back to one implementation fact: it's the position of a hook call in the sequence, not any name attached to it, that React uses to track its state. There is no string anywhere connecting useState("") to a particular slot. React keeps a list per component and a pointer that advances with each hook call, so your third useState is "the third slot", identified by nothing else.
The violation that actually happens in real code is rarely the textbook if (x) useState(). It is an early return that grows above a hook during a refactor:
function ReviewDrawer({ candidateId }) {
const { detail, status } = useCandidateDetail(candidateId);
if (status !== "success") return <Spinner />; // added later, seemed harmless
const [notes, setNotes] = useState(""); // hook 2 — sometimes reached, sometimes not
useEffect(() => { ... }, [notes]); // hook 3 — same
// ...
}
Follow the slots. While loading, React runs hook 1 and stops — the component registers one hook. When the data arrives it runs all three, and React finds slots 2 and 3 where it expected nothing. On a later render the condition flips back and the list shrinks again. React either throws "Rendered fewer hooks than expected" or, in the nastier version, silently hands slot 2's value to a different hook, and your notes state becomes an effect's dependency.
The fix is always the same shape: keep every hook above every conditional return, and put the condition in the returned JSX or in a wrapper component instead.
function ReviewDrawer({ candidateId }) {
const { detail, status } = useCandidateDetail(candidateId);
const [notes, setNotes] = useState(""); // all hooks, unconditionally
useEffect(() => { ... }, [notes]);
if (status !== "success") return <Spinner />; // conditions below them
// ...
}
Install eslint-plugin-react-hooks and let it enforce both rules. It catches these statically, and the same analysis is what React's compiler relies on to be able to optimise your components at all.