#Next.js#React

React Hooks: Deep Dive and Common Pitfalls

Hooks are easy to use, but details around useRef, useMemo, and dependency arrays trap many developers. This article explains the Hooks mental model of closures and dependencies, common pitfalls such as stale closures and infinite loops, custom Hook extraction principles, and why useEffect is not a lifecycle method.

Key Points

  • Topic Focus: Next.js、React
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Getting started with Hooks is genuinely easy: write a useState, add a useEffect, and nine times out of ten it runs. But there are several hurdles between "it runs" and "I can truly control it." Many community bugs -- state resetting halfway through typing in an input, useEffect endlessly calling an API, clicks receiving old values, or lint reporting a dependency warning that gets dismissed with a one-line comment -- all stem from the same root problem: treating Hooks as APIs to memorize without building the mental model behind them.

Hooks are neither lifecycle methods nor magic. Their underlying mechanism is simple enough to have only two parts: on every render, the component function executes again from beginning to end; the variables captured during each execution form that frame's closure. Once you understand these two points, every later question -- "why did I get an old value here?", "why did the effect run twice?", and "why is lint yelling at me?" -- has a clear answer. This article does not cover API signatures. It covers the mental model, four classic pitfalls, the real role of useRef, principles for extracting custom Hooks, and what it really means that "useEffect is not a lifecycle method."

1. Mental Model: Function Re-executes on Every Render + Closure Capture

In the era of class components, this.state was a persistent object, this pointed to the same instance, and reading this.state.count at any time gave you "the current latest value." Hooks completely overturn this model.

A function component does not have a "persistent instance." On every render, React calls your component function, executes it from the first line to the last, produces a new element tree, and then that function scope ends. On the next render, React calls the function again -- a new, entirely fresh scope. This means:

tsx
function Counter() {
  const [count, setCount] = useState(0);
  // count here is a snapshot from "this render," not a continuously updating live value
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

count is not "a variable that changes." It is "a snapshot of state taken during this render." After clicking the button three times, during the fourth render, count in that frame is 3, and every place that references count throughout the component tree is 3 during that frame. On the next render, there is a new function call and the count in the new closure is 4.

This "a new closure on every render" model is the foundation for understanding every pitfall that follows. A closure does not capture "the latest value of a variable"; it captures "the value from this render." When you read an outer variable in setTimeout, an event callback, or useEffect, you always read the snapshot taken by that render, not the value "now" -- this is the root of stale closures.

2. useState / useReducer: Batching and Functional Updates

useState looks simple, but you will run into trouble if you do not understand two details: batching and functional updates.

Since React 18, all state updates are batched by default. Even if you call a setter multiple times in succession inside setTimeout or a Promise callback, React combines them into one render. So calling setCount(c + 1) three times in a row only adds 1, because all three calls read the same old value from one frame:

tsx
// Anti-pattern: all three sets are based on count from the same frame, so the final result is only +1
const handleTripleBad = () => {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
};

// Correct: functional updates accumulate from "the latest previous value" each time
const handleTripleGood = () => {
  setCount((c) => c + 1);
  setCount((c) => c + 1);
  setCount((c) => c + 1);
};

The rule is simple: new state depends on old state -> use a functional update setX(prev => ...). This also solves one category of stale-closure scenarios: you do not need to "read the latest count"; you only need to tell React "how to turn the previous value into the next one."

Once state becomes complex enough that three or four fields interact with one another (typical examples: request state with loading/error/data, or multi-step forms), stop piling up useState calls and use useReducer instead. Centralizing state transition logic in one reducer is much clearer and easier to test than setters scattered everywhere.

3. The Truth About useEffect: It Is Not a Lifecycle Method, but a "Declared Synchronization Side Effect"

This is the most misunderstood part of Hooks. Many people treat useEffect as a combination of the class component methods componentDidMount + componentDidUpdate + componentWillUnmount, then write "call an API once when the component loads" or "clear a timer when the component unmounts" inside it and settle the matter with an empty dependency array [].

This mental model is wrong, and it leads to bugs caused by missing dependencies.

The correct model is: useEffect is a "declaration of a synchronization side effect." You tell React: "I want this side effect to stay synchronized with these dependencies -- whenever anything in deps changes, run it again and clean up the previous one." The dependency array is not a switch for "when to trigger"; it is an honest list of "which values this side effect cares about."

  • useEffect(fn, [a, b]): the side effect depends on a and b; whenever either changes, it resynchronizes (clean up the old one, run the new one).
  • useEffect(fn, []): the side effect depends on nothing, so it synchronizes only once at mount.
  • No array: resynchronize on every render -- almost never needed.

An empty array does not mean "mount". It is merely the special case where "this effect declares that it depends on no values." If you read an outer props.id inside it but do not include it in the dependencies, you are lying: the effect uses id, but you tell React "I have nothing to do with id." React will keep using the first id and will not fetch again when id later changes -- the classic stale-data bug. The correct approach is to honestly put every value you use into the dependencies and let React resynchronize for you.

4. The Four Classic Pitfalls: Each Has a Root Cause

Pitfall 1: stale closure (getting old values)

tsx
// Anti-pattern: the setInterval callback always captures the first count and always shows 0
function Timer() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000); // count is always 0
    return () => clearInterval(id);
  }, []); // Empty dependencies -> effect runs once -> closure is locked to the count=0 frame
  return <span>{count}</span>;
}

// Fix 1: functional update; do not read outer count
useEffect(() => {
  const id = setInterval(() => setCount((c) => c + 1), 1000);
  return () => clearInterval(id);
}, []);

// Fix 2: put count in the dependencies and let it resynchronize (but setInterval will be repeatedly rebuilt)

Remember: variables in a closure are "snapshots from that frame." Either use a functional update to avoid depending on outer values, or honestly put the value in deps.

Pitfall 2: missing dependencies

Enable the react-hooks/exhaustive-deps rule in eslint-plugin-react-hooks and set it to error. Its warning is not about "code style"; it means "there is a real stale-closure risk here." When you see a warning, fix the dependencies; do not suppress it with // eslint-disable-next-line. Every suppression is a future landmine. If "adding the dependency causes an infinite loop," pitfall 3 is already waiting for you: the root cause is the effect's logic itself.

Pitfall 3: infinite loops

tsx
// Anti-pattern 1: putting an object/array with a new reference on every render into dependencies
useEffect(() => {
  fetch("/api").then(setData);
}, [data]); // data is state; setData changes data -> effect runs again -> setData again -> infinite loop

// Anti-pattern 2: using an inline object as a dependency
useEffect(() => {
  doSomething({ id });
}, [{ id }]); // A new object on every render; shallow comparison is always unequal -> runs every frame

Infinite loops almost always follow the same pattern: the effect calls setState, and its dependencies are driven by that state (or by a newly created reference every time). The solution is to distinguish "what this effect truly depends on." If it depends on a primitive value (id, query), include only that primitive. If it depends on a derived object, stabilize the calculation result with useMemo, or simply expand the object into primitive dependencies by field. [] cannot save you; it only replaces the loop with staleness.

Pitfall 4: calling Hooks inside conditions or loops

tsx
// Anti-pattern: conditionally calling a Hook violates the Rules of Hooks
if (ready) {
  const [data, setData] = useState(null); // Error: Hook order is disrupted
}

Hooks use call order to match state -- React internally stores each render's hook state in a linked list in order. Once you place a hook in a condition or loop, that order can change between renders, and React immediately throws an error. The rule is absolute: Hooks can only be called at the top level of a component. Want to enable one conditionally? Put the condition inside the hook rather than wrapping the hook:

tsx
// Correct: the hook is always called at the top level; the condition goes inside the hook implementation
const data = useFetch(id, { enabled: ready });

5. useRef: The Underestimated "Box"

useRef is often reduced to "for storing DOM nodes." That is true, but its real role is more useful: a mutable box that does not trigger rerenders. The key difference from useState is that changing ref.current does not rerender the component, and the ref object's reference itself never changes throughout the component lifecycle.

Together, these two properties allow useRef to do two things useState cannot:

  1. Access the DOM: <div ref={el => (boxRef.current = el)} />, the classic use case.
  2. Keep a mutable reference to the "latest value" across renders -- another tool for breaking stale closures, especially for scenarios where "an event callback needs the latest props/state, but should not trigger resubscription":
tsx
// Store the latest props in a ref so an effect that "subscribes only once" can still access the latest value
function SearchBox({ query }: { query: string }) {
  const queryRef = useRef(query);
  useEffect(() => { queryRef.current = query; }); // Synchronize the latest value on every render

  useEffect(() => {
    // Subscribe only once at mount, but the callback always reads the latest query
    const handler = () => console.log("current query:", queryRef.current);
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, []);
  return <input value={query} /* ... */ />;
}

This is the standard pattern for "I do not want to resubscribe when dependencies change, but I need the latest value" -- also the underlying implementation idea behind useEvent / useLatest. Do not misuse refs as a replacement for state: if changes to a value must drive the UI, use useState; changing a ref does not refresh the UI.

6. useMemo / useCallback: Most of the Time, You Do Not Need Them

One of the community's biggest misconceptions is treating useMemo / useCallback as default patterns. The truth is that they have their own costs (shallow dependency comparisons on every render plus memory for caching), and they are worthwhile only when "the work saved > the cost introduced."

  • useMemo: meaningful only when the computation is actually expensive (sorting/filtering a large list, complex formatting) and would run repeatedly.
  • useCallback: needed only when you pass the function to a child component that is wrapped in memo and truly rerenders because its reference changes.

Most values wrapped in useMemo are already fast without it. This is covered in detail in React Performance Optimization in Practice; here there is only one hard rule: adding memoization by feel is wrong. Measure first with the Profiler, then decide. When the reason you want useCallback is "I heard it improves performance" rather than "measurement showed unstable props causing unnecessary child rerenders here," do not add it.

7. Custom Hooks: Extract State Logic, Prefer Composition Over Inheritance

Before Hooks, React could reuse logic only through HOCs and render props -- deeply nested layers and confusing prop origins. Hooks provide a clean path: extract a piece of state + side-effect logic into a function and return the values and operations it needs. There are three extraction principles:

  1. Extract by "one thing," not by "one line of code." useDebounce, useFetch, useLocalStorage, and useMediaQuery all state clearly what they do, which makes their boundaries clear.
  2. Keep return values restrained: use [value, setValue] or an object with stable fields and clear meanings.
  3. Prefer composition over inheritance: a custom Hook can call other Hooks internally. useFetch can combine useState / useEffect / useAbortController to compose complex capabilities.
tsx
// A practical useDebounce: delay a frequently changing value until it has stopped changing for N ms
function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id); // Clear the old timer whenever value changes to avoid jitter
  }, [value, delay]);
  return debounced;
}

// Usage: debounced input search, as clean as writing configuration
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
  if (debouncedQuery) search(debouncedQuery);
}, [debouncedQuery]);

The criterion for whether to extract a piece of logic into a Hook is "will it be reused in a second place?" If yes, extract it. If not, do not extract merely for the sake of extraction; leaving it in the component is clearer.

8. Debugging and Defense: DevTools + Dependency Checks

The standard defensive toolkit has two pieces:

  • eslint-plugin-react-hooks, with exhaustive-deps set to error -- catch stale closures in the editor instead of letting users find them after release.
  • React DevTools' Profiler + Components panels: the Components panel lets you directly inspect the current hook values of every component; record an interaction in the Profiler to see "which render, why it rendered, and how long it took." You can see at a glance how many times an effect ran and what triggered each run.

One more practical tip: do not panic when you see an effect run twice in development. This is deliberate behavior in React 18+ Strict Mode (mount -> unmount -> remount), intended to expose whether your effect cleanup is correct. If your cleanup is wrong (a subscription is not canceled, a timer is not cleared, or a listener is not removed), Strict Mode surfaces it during development. Fix it before release rather than making users suffer a memory leak in production.

Summary

  1. The Hooks mental model has only two rules: the component function executes from the beginning on every render; variables captured in each execution form that frame's closure. count is not a "live value"; it is "a snapshot from this render." Every stale closure comes from failing to recognize that "the closure captured an old snapshot."
  2. useEffect is not a lifecycle method; it is a "declared synchronization side effect." The dependency array is an honest list of "which values this side effect cares about." An empty array is merely the special case of "depends on nothing," not syntactic sugar for mount. Put every used value in the dependencies instead of hiding the problem with // eslint-disable.
  3. Each of the four classic pitfalls has a remedy: use functional updates or a ref holding the latest value for stale closures; rely on exhaustive-deps for missing dependencies; distinguish "the primitive value truly depended on" from "a newly created reference on every render" for infinite loops; Hooks can only be called at the top level of a component, so put conditions inside the implementation rather than around the call.
  4. useRef is "a mutable box that does not trigger rerenders": beyond DOM access, it is the key tool for "holding the latest value across renders without triggering resubscription." useMemo / useCallback are optimization tools, not default syntax; measure first before deciding whether to add them.
  5. Custom Hooks are the clean way to reuse state logic (useDebounce / useFetch / useLocalStorage): extract by "one thing" and prefer composition over inheritance. Pair react-hooks/exhaustive-deps with DevTools to stop bugs during development.

Key takeaway: Hooks are not lifecycle methods; they are "a new closure every frame + a dependency list." Once you understand "which render's snapshot is the value in my hand?" and "what does this effect actually depend on?", the remaining pitfalls collapse on their own.

Related

Related Content