Rreact.wiki
← Blog

The Hidden Cost of useEffect Dependencies: When [] Lies to You

useEffect’s empty dependency array `[]` promises “run once,” but closures, stale state, and prop drift often break that promise—here’s how to detect and fix it with ESLint, DevTools, and modern patterns.

The Empty Array Trap

You’ve written it a thousand times:

TSX
useEffect(() => {
  const timer = setTimeout(() => {
    console.log('Hello from mount!');
  }, 1000);
  return () => clearTimeout(timer);
}, []);

It feels safe. It looks correct. And for simple side effects—like logging on mount or initializing analytics—it often works. But [] doesn’t mean “run once, forever.” It means “run once per component instance, using the values captured at that instance’s creation.” That subtle distinction is where bugs hide.

When useEffect closes over props or state, those values are frozen in time—even if the component re-renders and those values change. This isn’t a React bug. It’s JavaScript closure behavior—and it’s the #1 source of silent, hard-to-debug race conditions in React apps.

Why [] Lies (and Why It’s Not Lying)

Consider this seemingly innocent counter example:

TSX
function Counter({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
 
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
 
  return <div>Count: {count}</div>;
}

At first glance: ✅ runs once, ✅ increments every second. But try changing initialCount via parent re-render (e.g., <Counter initialCount={Math.random()} />). The effect still runs once—but it always reads the original initialCount, and more critically, it always calls setCount(c => c + 1) using the first render’s closure of setCount. That part is fine—but what if you need to read count inside the effect?

TSX
useEffect(() => {
  const id = setInterval(() => {
    console.log('Current count:', count); // ❌ Stale!
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

Now count is always initialCount, no matter how many times the component re-renders. The interval logs the same stale value forever.

This isn’t [] lying—it’s doing exactly what you asked: capturing the count from the first render. But your intent was likely “log the current count every second.” That mismatch between intent and implementation is the hidden cost.

The Three Stale Value Culprits

1. Stale Props/State in Callbacks

Any value used inside the effect body that isn’t in the dependency array becomes stale:

TSX
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
 
  useEffect(() => {
    fetchUser(userId).then(setUser); // ✅ userId is in deps
  }, [userId]); // ✅ Correct
 
  // ❌ Danger: Using `userId` inside a callback *without* including it
  useEffect(() => {
    const handleRouteChange = () => {
      console.log('Fetching user:', userId); // Stale if userId changes later
      fetchUser(userId).then(setUser);
    };
    window.addEventListener('popstate', handleRouteChange);
    return () => window.removeEventListener('popstate', handleRouteChange);
  }, []); // ❌ Missing userId → stale forever
}

2. Stale Functions (Event Handlers, API Calls)

Functions defined inside the component also close over values. If you define fetchUser inline and don’t memoize it, you’ll get new instances on every render—but [] won’t trigger re-subscription:

TSX
useEffect(() => {
  const fetchData = () => fetch(`/api/users/${userId}`).then(r => r.json());
  fetchData().then(setUser);
}, []); // ❌ fetchData is recreated each render, but effect only runs once → stale userId

3. Stale Refs Without Proper Cleanup

Refs can help escape closure—but only if you keep them up to date:

TSX
const countRef = useRef(count);
useEffect(() => {
  countRef.current = count; // ✅ Keep ref synced
}, [count]);
 
useEffect(() => {
  const id = setInterval(() => {
    console.log('Live count:', countRef.current); // ✅ Always current
  }, 1000);
  return () => clearInterval(id);
}, []);

Without the syncing effect, countRef.current would remain stuck at its initial value.

Detecting Stale Dependencies: ESLint to the Rescue

The eslint-plugin-react-hooks rule exhaustive-deps is your first line of defense. It warns when values used in an effect aren’t declared in the dependency array.

Enable it in .eslintrc.cjs:

JavaScript
module.exports = {
  plugins: ['react-hooks'],
  rules: {
    'react-hooks/exhaustive-deps': 'warn',
  },
};

It catches obvious cases like:

TSX
useEffect(() => {
  console.log(name); // ⚠️ 'name' is not in the dependency array
}, []); // ESLint will flag this

But ESLint can’t catch logical staleness—like using a function that itself closes over stale values. That’s where React DevTools comes in.

Debugging With React DevTools: The “Why Did This Render?” Secret

React DevTools’ “Highlight Updates” and “Render Count” features expose staleness indirectly—but the real superpower is the “Why did this render?” tooltip.

Here’s how to use it:

  1. Open DevTools → Components tab
  2. Click the gear icon → Enable Highlight updates when components render
  3. Trigger a state/prop change that should update your effect—but doesn’t
  4. Hover over the component name → click the “ℹ️” icon → select Why did this render?

If your effect depends on userId but userId changed and the effect didn’t re-run, DevTools will show:

“Effect wasn’t re-run because userId is missing from its dependency array.”

More subtly: if you see repeated renders without your effect firing, suspect stale closures.

You can also add debug logs inside the effect to confirm staleness:

TSX
useEffect(() => {
  console.log('[Effect] userId:', userId, 'count:', count, 'timestamp:', Date.now());
  // …
}, [userId]); // Now you’ll see when it *does* run—and what it sees

Fixing the Problem: Patterns That Scale

✅ Prefer useCallback for Stable Functions

TSX
const fetchUser = useCallback(() => {
  return fetch(`/api/users/${userId}`).then(r => r.json());
}, [userId]);
 
useEffect(() => {
  fetchUser().then(setUser);
}, [fetchUser]); // ✅ Now effect re-runs when fetchUser changes

✅ Use Functional Updates for State-Dependent Logic

Instead of reading count directly, use the functional form of setState:

TSX
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => {
      console.log('Updating from:', prev); // ✅ Always current
      return prev + 1;
    });
  }, 1000);
  return () => clearInterval(id);
}, []);

✅ Leverage useRef + Sync Effect for Complex Cases

When you truly need a mutable value that stays in sync across renders:

TSX
const userIdRef = useRef(userId);
useEffect(() => {
  userIdRef.current = userId;
}, [userId]);
 
useEffect(() => {
  const handler = () => {
    console.log('Handling for:', userIdRef.current); // ✅ Always fresh
  };
  window.addEventListener('custom-event', handler);
  return () => window.removeEventListener('custom-event', handler);
}, []);

✅ Consider useSyncExternalStore for External Stores (React 18+)

For subscriptions to external state (e.g., Zustand, Redux), prefer useSyncExternalStore over useEffect—it’s designed to avoid staleness by design.

When [] Is Actually Safe

Not all [] usage is dangerous. It’s safe when:

  • You only use values that never change (e.g., DOM refs, stable callbacks from useCallback, constants)
  • You only perform side effects that don’t depend on props/state (e.g., addEventListener on window, setTimeout with hardcoded values)
  • You explicitly opt into staleness by design (e.g., analytics page-view tracking on mount only)

Example of safe []:

TSX
useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Escape') closeModal();
  };
  window.addEventListener('keydown', handleKeyDown);
  return () => window.removeEventListener('keydown', handleKeyDown);
}, []); // ✅ Safe: closeModal is stable, no props/state read

Final Thought: Trust the Linter, Verify With DevTools, Design for Freshness

The empty dependency array isn’t evil—it’s a sharp tool. Its danger lies in misalignment between developer intent and JavaScript reality. By combining ESLint’s static analysis, React DevTools’ runtime insight, and deliberate patterns like useCallback, functional updates, and ref synchronization, you turn [] from a footgun into a precise instrument.

Next time you type [], pause. Ask: What values does this effect actually need—and are they guaranteed to be fresh? If the answer isn’t a confident “yes,” reach for the right tool—not just the shortest syntax.

Because in React, the most expensive bug isn’t the one that crashes your app. It’s the one that works… until it doesn’t.