React|React operation principles and state management

The Lies of useEffect's Dependency Array: Solving Stale Closures and When to Use useLayoutEffect

1
The Lies of useEffect's Dependency Array: Solving Stale Closures and When to Use useLayoutEffect

Chris's timer app is acting strange. The number should go up every second, but it either gets stuck at 1 or jumps unexpectedly.

// ❌ Buggy Timer
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // Prints 0 repeatedly!
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(id);
  }, []); // Empty dependency array (The root cause)
  
  return <div>{count}</div>;
}

VS Code's linter waves a yellow flag and warns:

"React Hook useEffect has a missing dependency: 'count'. Either include it or remove the dependency array."

But Chris thinks:

"No way, if I add count, useEffect will re-run every time and reset my timer. I'll just ignore it."

This is the exact moment beginners fall into the most common trap: The Lie of the Dependency Array and the Stale Closure problem.

1. The Cost of the Lie: Stale Closure

In the code above, the arrow function inside setInterval was created when the component was first rendered. At that time, count was 0. This function is trapped forever in a world (closure) where count is 0.

  • First Render (count = 0): useEffect runs, setInterval starts.
  • 1 second later: setCount(0 + 1) is called. -> count becomes 1.
  • Re-render (count = 1).
  • However, useEffect does not run again because of the empty dependency array ([]).
  • setInterval keeps running the old function (which remembers 0).
  • 1 second later: setCount(0 + 1) is called again. -> count is still 1.
  • This is Stale Closure. It is referencing a variable that has gone stale.

    Solution 1: Tell the Truth (Dependency Array)

    Just listen to the linter. However, this creates a problem where setInterval is initialized every time.

    // ⚠️ Works, but inefficient
    useEffect(() => {
      const id = setInterval(() => {
        setCount(count + 1);
      }, 1000);
      return () => clearInterval(id);
    }, [count]); // Clears and resets the timer every time count changes

    Solution 2: Functional Updates (Best Practice)

    Instead of reading the count value directly, simply instruct React to "add 1 to the current value."

    // ✅ Clean dependency array, timer persists
    useEffect(() => {
      const id = setInterval(() => {
        setCount(prev => prev + 1); // Does not depend on count!
      }, 1000);
      return () => clearInterval(id);
    }, []);

    2. When You Need useLayoutEffect

    For most side effects, useEffect is sufficient. However, there are cases where screen flickering occurs.

    Scenario: You need to calculate the position of a tooltip. You render the tooltip first (useEffect), measure its height, and then adjust the position (setState).

  • Render: Tooltip renders at (0, 0).
  • Paint: User sees the tooltip at (0, 0). (Flicker!)
  • useEffect: Calculates height and moves it to (100, 200).
  • Repaint: Tooltip moves to the correct position.
  • The user witnesses the tooltip "teleporting." This is when you need useLayoutEffect.

  • useEffect: Render → Paint → Effect runs (Asynchronous)
  • useLayoutEffect: Render → Effect runs → Paint (Synchronous)
  • Code inside useLayoutEffect runs synchronously just before the browser paints the screen. If you manipulate the DOM here, the user sees only the finally calculated (moved) screen.

    // tooltip.tsx
    useLayoutEffect(() => {
      const { height } = ref.current.getBoundingClientRect();
      setY(height + 10);
      // The browser waits to paint the screen until this calculation is finished.
    }, []);

    Key Takeaways

  • Stale Closure: When using external variables inside useEffect, you must include them in the dependency array. Otherwise, it remembers old values.
  • Functional Updates: Using setCount(prev => prev + 1) allows you to remove the state from the dependency array, making it safer and cleaner.
  • useLayoutEffect: Use this only when you want to prevent flicker caused by DOM measurements or layout changes. For everything else, useEffect is better for performance.

  • Now we've learned how to avoid the traps of Hooks. But what if the state isn't just a single number, but a complex object or multiple intertwined values? There comes a time when useState alone isn't enough.

    Continuing in: "useState vs useReducer: The Tool to Choose When State Gets Complex."

    🔗 References

  • A Complete Guide to useEffect (Overreacted)
  • React Docs - useEffect
  • React Docs - useLayoutEffect