React Hooks have been the default way to write React components for years, and most engineers learn the API quickly — the syntax is simple enough to pick up in an afternoon. But the mental model that makes Hooks work predictably takes longer to build. The questions that keep appearing in code reviews and production debugging ("why is my effect running twice?", "why is my state one render behind?", "what goes in the dependency array?") are almost always mental model problems, not syntax problems.
This article rebuilds the mental model from first principles, covering useState, useEffect, the dependency array, useCallback, useMemo, and useRef. The goal is to explain the mechanism clearly enough that you can reason through any Hook-related problem, not just recognise the patterns you have already seen.
react's rendering model: the foundation
Before Hooks make sense, you need a clear picture of what React does when it renders. A React component is a function. React calls that function to get back a description of the UI — a tree of React elements — and reconciles that description with the existing DOM. Every time React needs to update the UI, it calls the function again and applies the minimal set of DOM changes needed to match the new description.
This means every render is a fresh function call. Every variable declared inside the component function is created fresh for that render. Every callback captures the values that were in scope when that particular function call ran. The DOM persists across renders; the component function's local variables do not.
What does persist across renders is the component instance — an entry in React's internal fiber tree, associated with the component's position in the component tree. Hooks store their values against that instance. This is why calling useState returns the same stored value on every render rather than reinitialising it — and why the order of Hook calls must be consistent across renders.
useState: a value that outlives the function call
useState solves the problem that a function's local variables do not survive between calls. When you call useState, React stores the value externally — associated with the component instance — and returns both the current value and a setter function.
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
);
}
The setter has two forms: setCount(newValue) and the updater form setCount(previousValue => newValue). The updater form is the right choice whenever the new state depends on the current state, because it always receives the most recent committed value — even when multiple updates are batched together. Using setCount(count + 1) when count comes from a closure captures the value from the last render, which causes incorrect results when multiple updates fire rapidly.
State updates in React 18 are automatically batched: multiple setState calls within the same event handler or useEffect are merged into a single re-render. The updated value is not visible until the next render — you cannot read the new state in the same synchronous execution path as the setter call.
useState initialises its value once, on the first render. Passing a new initial value on subsequent renders has no effect. If the initial value requires an expensive computation, pass a function: useState(() => computeExpensiveDefault()). React calls the function once on the first render and caches the result.
useEffect: synchronization, not lifecycle
useEffect is where most React bugs live, and the source is almost always the wrong mental model.
The instinct is to think of useEffect as componentDidMount, componentDidUpdate, and componentWillUnmount combined. That framing works for simple cases but breaks apart as soon as your effect depends on props or state. The more accurate mental model: useEffect synchronises your component with something external. The external thing can be a data fetch, a DOM API, a timer, a subscription, a third-party library — anything that lives outside React's rendering system.
function UserProfile({ userId }) {
const [profile, setProfile] = useState(null);
useEffect(() => {
let cancelled = false;
fetchUser(userId).then(data => {
if (!cancelled) setProfile(data);
});
return () => {
cancelled = true;
};
}, [userId]);
if (!profile) return <span>Loading...</span>;
return <div>{profile.name}</div>;
}
Every time userId changes, React runs the cleanup function from the previous effect (setting cancelled = true to discard the in-flight fetch) and runs the new effect (starting a fresh fetch for the new userId). The component always reflects the current userId, and stale responses from old fetches are silently discarded.
React 18's Strict Mode runs effects twice in development — mount, unmount, mount again — specifically to surface missing cleanup functions. If your effect behaves incorrectly on the second mount, you have a cleanup problem that would also appear in production when components unmount and remount. This is intentional, not a bug to work around.
the dependency array: a declaration, not an optimisation
The second argument to useEffect is the dependency array, and it is the source of more confusion than any other part of the Hooks API. The correct way to think about it: the dependency array declares which values from outside the effect the effect reads. When any of those values change, React re-synchronises the effect.
The correct rule is: include every reactive value the effect reads. A reactive value is anything that can change across renders — props, state, context values, and any variables or functions derived from them. If you omit a reactive value from the dependency array, the effect runs with a stale closure, seeing the value from the render it last ran on even as the actual value changes in subsequent renders.
// Bug: count is read inside the effect but not declared as a dependency.
// The effect always sees count = 0.
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // always count + 1 = 0 + 1 = 1
}, 1000);
return () => clearInterval(id);
}, []);
// Fix: use the updater form so the effect does not read count at all.
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
An empty dependency array ([]) means the effect has no reactive dependencies — run once after the first render and never re-synchronise. This is only correct when the effect genuinely does not read any reactive values. It is wrong for effects that need to stay in sync with changing props or state.
The ESLint react-hooks/exhaustive-deps rule enforces the dependency array contract automatically. It should not be suppressed without a specific, understood reason. In the vast majority of cases, an exhaustive-deps warning is pointing at a real bug.
useCallback: stabilising function references
useCallback returns a memoised version of a function that only changes when its dependency array changes. The most common use cases are passing a stable function reference to a component wrapped in React.memo, and including a function in the dependency array of another Hook without causing it to fire on every render.
// Without useCallback, handleSubmit is a new function object on every render.
// If Form is memoised, it re-renders anyway because the prop reference changed.
const handleSubmit = useCallback((formData) => {
submitToApi(formData, userId);
}, [userId]);
<Form onSubmit={handleSubmit} />
The common mistake is wrapping every function in useCallback "for performance." This is cargo-culting. useCallback has a real cost: React must store the function, evaluate the dependency array, and compare it to the previous run on every render. For event handlers passed directly to DOM elements (<button onClick={...}>), this overhead is pure waste — the DOM does not care about function reference stability.
Reach for useCallback only when reference stability actually matters: memoised child components where an unstable function prop defeats the memoisation, and effect dependencies where an unstable function would cause the effect to fire on every render.
useMemo: memoising computed values
useMemo memoises the result of a computation, only recomputing it when the dependency array changes. The pattern mirrors useCallback for values rather than functions.
// Only recomputed when items or filterTerm changes, not on every render.
const filteredItems = useMemo(() => {
return items.filter(item =>
item.name.toLowerCase().includes(filterTerm.toLowerCase())
);
}, [items, filterTerm]);
useMemo is appropriate when a computation is demonstrably expensive — measured with the React DevTools Profiler, not assumed. It is also appropriate when you need a stable object or array reference: if a derived object is created inside the component body, it is a new reference on every render, which would trigger re-renders in memoised children that receive it as a prop.
Like useCallback, useMemo has overhead. Do not reach for it preemptively. Most computations are fast enough that the memoisation cost exceeds the computation cost. Profile first, then optimise.
useRef: the stable mutable container
useRef creates a mutable object — { current: value } — that persists across renders without triggering a re-render when it changes. That combination makes it the right tool for two distinct patterns.
The first is DOM access. When a ref prop is attached to a DOM element, React sets ref.current to the DOM node after the element mounts and clears it when the element unmounts.
function SearchInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} type="search" />;
}
The second pattern is storing a mutable value that needs to persist across renders but must not trigger a re-render when it changes — a timeout ID, an AbortController, a previous value for comparison, a flag tracking whether the component is still mounted. Using useRef for these avoids the unnecessary re-render that useState would trigger.
One antipattern to avoid: using useRef to hold a reactive value and reading it inside an effect to avoid adding the value to the dependency array. This suppresses the exhaustive-deps warning while leaving a stale closure bug intact. The right fix is to restructure the effect so the reactive value can be included as a dependency.
the rules of hooks and why they exist
React enforces two rules: only call Hooks at the top level of a component (never inside loops, conditions, or nested functions), and only call Hooks in function components or custom Hooks.
Both rules exist because React identifies which Hook call corresponds to which stored value by the order in which Hooks are called on each render. React does not track Hooks by name or by a developer-provided key — it tracks them by call position. If a Hook is called conditionally, the position shifts on renders where the condition is false, and React associates the wrong stored value with the wrong Hook call. The result is confusing state bugs or a crash.
Custom Hooks are the composability mechanism that makes this system scale. Any function that calls React Hooks is itself a Hook by convention — prefixed with use — and can be tested in isolation, shared across components, and composed into more complex logic. A custom Hook is just a function; its only special property is that it can call other Hooks.
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
}
common pitfalls in production code
Stale closures in effects with missing dependencies. The canonical version: an effect reads a prop or state value, the dependency array is empty or incomplete, and the effect always sees the initial value because it captured a stale closure from the first render. The fix is almost always to complete the dependency array and, if the effect would fire too often, restructure it so it does not need the reactive value directly (using the updater form of setState is a common pattern).
Object and array dependencies that are new references on every render. An object or array created in the component body is a new JavaScript object on every render, even if its contents are the same. An effect that depends on such an object re-runs on every render. The fix is to use primitive values as dependencies where possible, move the object definition inside the effect, or memoize it with useMemo. This also applies to useCallback's dependency array.
Missing or incorrect cleanup. Any effect that starts an asynchronous operation, opens a subscription, or acquires a resource must return a cleanup function that terminates or releases it. A missing cleanup causes race conditions (stale responses overwriting fresh state), memory leaks (intervals and subscriptions running after the component unmounts), and the Strict Mode double-mount test failures that are often the first symptom developers encounter. The cleanup function must fully reverse what the setup function did.
Reading state immediately after setting it. State updates are applied on the next render. Reading state in the same synchronous pass as the setter call will return the value from the current render, not the updated one. If you need to derive a new value from the updated state, compute it directly from the new value rather than reading state again.
The underlying pattern in almost every Hook-related bug is the same: the code assumes that variables in a closure reflect current application state, when in fact they reflect the state at the time the closure was created. Building the mental model — components are functions that run per-render, closures capture that render's values, Hooks store values outside the function — makes these bugs visible before they reach production.