Understanding React Hooks in Depth
- react
- react-hooks
- javascript
- typescript
- frontend
- webdev
- software-architecture
- nextjs
In React Fundamentals for Professionals I covered the mental model behind components, rendering, and the virtual DOM. Hooks sit on top of that model, and if you skipped straight to useState and useEffect without understanding why they work the way they do, you will eventually hit a bug that looks impossible until you see the closure underneath it.
This article is part of the React & Next.js Complete Guide series. The goal here is not to list the hooks API; the official docs already do that well. Instead I want to build the mental model that makes useEffect dependency arrays, stale closures, and custom hooks feel obvious instead of magical.
I am writing this from the same place I write the rest of this series: a working full stack engineer who has shipped this stuff in production SaaS apps, not a framework author explaining internals for their own sake.
Why hooks exist in the first place
Before hooks, stateful logic in React lived in class components through this.state and lifecycle methods like componentDidMount and componentDidUpdate. Classes themselves weren't really the issue. Related logic ended up scattered across lifecycle methods organized by when code runs, rather than by what the code is for.
Say a component subscribes to a WebSocket connection and also tracks window resize events. In a class component, both subscriptions get set up in componentDidMount and torn down in componentWillUnmount, interleaved with unrelated setup. Six months later, nobody can tell which cleanup line belongs to which subscription without reading closely.
Hooks fix this by letting you colocate a piece of stateful logic with its own effect and cleanup, then extract the whole thing into a custom hook with a descriptive name. The unit of reuse changes from "a class you extend or wrap" to "a function you call," which composes better.
The other problem hooks solved was reuse. Sharing stateful logic used to mean higher-order components or render props, both of which wrap your tree in extra layers and make the DevTools tree harder to read. A custom hook adds zero extra components to the tree. It is just a function.
The mental model: hooks are closures tied to a fiber
This is the part most engineers skip, and it is the part that actually matters. React does not store hook state on your component's local variables. When your function component runs, React associates an internal linked list of "hook slots" with that component's fiber (its internal representation in the render tree). Each call to useState, useEffect, useRef, and so on claims the next slot in that list, in the order those calls happen.
This is exactly why the rules of hooks exist:
// Never do this: the number and order of hook calls must be identical on every render
function UserProfile({ userId }: { userId: string }) {
if (userId) {
const [name, setName] = useState(""); // conditional hook call: breaks slot alignment
}
const [email, setEmail] = useState("");
// ...
}
If the number of hook calls changes between renders, React's internal slot list gets misaligned with your code, and state from one hook silently ends up being read as if it belonged to another. The eslint-plugin-react-hooks rules-of-hooks rule exists specifically to catch this at lint time, and it should be a hard error in any production codebase, not a warning.
The other consequence of this model is closures. Every time your component function runs, a brand new closure is created for every function inside it, including the callbacks you pass to useEffect. That closure captures the props and state values as they were during that specific render. This single fact explains almost every "stale state" bug you will encounter.
function PollingCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// This closure was created once, on mount, and "count" here
// is permanently frozen at 0 because the effect's dependency array is empty.
console.log("count is", count);
setCount(count + 1); // always sets to 1, never increments further
}, 1000);
return () => clearInterval(id);
}, []); // empty array: effect runs once, closure never refreshes
return <div>{count}</div>;
}
The fix is either to include count in the dependency array (which recreates the interval every second, wasteful) or to use the updater form of setState, which does not depend on a captured value at all:
useEffect(() => {
const id = setInterval(() => {
setCount((prev) => prev + 1); // reads current state at update time, not at closure-creation time
}, 1000);
return () => clearInterval(id);
}, []);
This is a case where deferring to the updater form is strictly better: it removes a dependency, avoids recreating the interval, and sidesteps the whole staleness question.
useEffect and the dependency array in production code
useEffect is the hook most engineers get wrong, usually by omitting dependencies to "make the warning go away," or by over-including them until the effect refires constantly. Both are symptoms of treating the dependency array as a lint chore instead of what it actually is: an accurate list of every reactive value the effect reads.
The exhaustive-deps ESLint rule is not being pedantic. If your effect reads a value from render scope and that value is missing from the array, your effect is working against a snapshot instead of current state, and you will eventually reproduce the stale closure bug above in a less obvious form (an API call using an old filter value, a subscription keyed to an old ID, and so on).
A pattern I use often in production data-fetching hooks is pairing the dependency array with an AbortController, so a fast component unmount or a rapid prop change does not leave a stale request able to overwrite fresher state:
function useUser(userId: string) {
const [user, setUser] = useState<User | null>(null);
const [status, setStatus] = useState<"idle" | "loading" | "error" | "success">("idle");
useEffect(() => {
const controller = new AbortController();
async function fetchUser() {
setStatus("loading");
try {
const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
const data: User = await res.json();
setUser(data);
setStatus("success");
} catch (err) {
if (controller.signal.aborted) return; // component unmounted or userId changed mid-flight
setStatus("error");
}
}
fetchUser();
return () => controller.abort();
}, [userId]);
return { user, status };
}
For anything beyond simple fetches like this, reach for React Query or SWR before hand-rolling more of this logic. Hand-written data fetching hooks are worth understanding, but they stop being worth maintaining once request deduplication, caching, and retry logic enter the picture. That complexity is worth deferring to a library rather than earning yourself.
Custom hooks: the real unit of reuse
A custom hook is a function whose name starts with use and that calls other hooks internally. That naming convention is not cosmetic; the linter uses it to know which functions to apply the rules of hooks to. Beyond that convention, a custom hook is genuinely just a function, and it should be designed like one: clear inputs, clear return type, no hidden global state unless that is explicitly the point.
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timeout = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timeout);
}, [value, delayMs]);
return debounced;
}
Extracting this into its own hook does more than avoid repetition: the debounce logic gets one place to test, one place to reason about, and callers never need to know it exists internally. A search input calling useDebouncedValue(query, 300) reads as intent; the debounce mechanics stay hidden inside the hook.
The pitfall with custom hooks is scope creep: a hook that starts as "debounce this value" grows into "debounce this value, also fetch, also cache, also log analytics." At that point it is doing several unrelated jobs and testing it means testing all of them together. Split it back into smaller hooks composed at the call site instead.
useMemo and useCallback: memoize with intent, not by default
useMemo and useCallback are the most over-applied hooks in the ecosystem. Every memoization has a cost: React stores the previous dependency array, compares it each render, and holds onto the cached value. That cost is trivial for most calculations, so wrapping every function and derived value in one of these hooks adds noise without measurable benefit.
The two cases where they earn their cost:
- The computation itself is expensive relative to a render (sorting or filtering a large list, for example), and
useMemoavoids redoing it every render. - You are passing a callback or object to a memoized child component (wrapped in
React.memo), and withoutuseCallback/useMemothat child re-renders every time because the reference changes, defeating the memoization.
Outside those two cases, reach for them only after observing a rendering cost worth solving with the React DevTools Profiler, not preemptively. Premature memoization is still premature optimization wearing a hooks-shaped disguise.
Key takeaways
- Hooks are ordered slots tied to a component's fiber, not local variables; conditional or looped hook calls break that ordering and produce corrupted state, which is why the rules of hooks are enforced by lint, not convention.
- Every render creates fresh closures. Values captured inside `useEffect` callbacks reflect the render they were created in, not the current render, which is the root cause of most "stale state" bugs.
- Prefer the updater form of `setState` (`setCount((prev) => prev + 1)`) over the dependency-value form whenever a callback fires asynchronously, since it removes a class of dependency-array bugs entirely.
- `useEffect` dependency arrays should be treated as accurate, not minimal; the `exhaustive-deps` lint rule exists because incomplete arrays are a correctness bug, not a style preference.
- Custom hooks are the primary unit of logic reuse in modern React. Keep them narrowly scoped, one responsibility per hook, and compose several small hooks at the call site instead of building one hook that does everything.
- Memoize (`useMemo`, `useCallback`) only after profiling shows a real cost. Default-memoizing everything adds comparison overhead without a corresponding benefit in most components.
Frequently asked questions
Why does my useEffect run twice in development?
In React 18's Strict Mode, components mount, unmount, and remount once in development specifically to surface effects that are not properly cleaned up. If your effect and its cleanup function are correctly paired (every subscription has a matching unsubscribe, every timer has a matching clear), the double invocation is harmless and does not happen in production builds.
What is the difference between useMemo and useCallback?
`useCallback(fn, deps)` is equivalent to `useMemo(() => fn, deps)`; it memoizes a function reference instead of a computed value. Use `useCallback` when you need a stable function reference (typically to satisfy a dependency array or avoid re-rendering a memoized child), and `useMemo` when you need a stable computed value.
Can I call hooks conditionally if the condition never changes across renders?
No. Even if the condition is stable in practice, the linter and React's runtime behavior both assume hook order can vary, and there is no supported way to opt out of that assumption. If you need conditional behavior, put the condition inside the hook body, not around the hook call.
Why did switching a class component to hooks change my component's behavior?
Class-based lifecycle methods and hook-based effects are not a one-to-one mapping. `componentDidMount` plus `componentDidUpdate` often get merged into a single `useEffect`, and if that effect's dependency array is not scoped correctly, it will run on renders where the equivalent class code would not have. Audit the dependency array against the exact conditions the old lifecycle methods checked for.
Should I use useRef instead of useState to avoid re-renders?
Only when the value truly does not need to trigger a re-render when it changes, such as tracking a mutable interval ID, a DOM node reference, or a previous-value comparison. If the UI needs to reflect the value, `useRef` is the wrong tool since updating a ref does not schedule a render.
Is it safe to call hooks inside custom hooks that are called conditionally elsewhere?
The custom hook itself must always be called unconditionally from the same position in its parent, just like a built-in hook. Whether the parent component decides to render the component containing that custom hook is a separate, and fine, concern; conditionally rendering a component is not the same as conditionally calling a hook inside a rendered component.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.