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.
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 changesSolution 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).
The user witnesses the tooltip "teleporting." This is when you need useLayoutEffect.
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
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."