Back to Blog
ReactJuly 9, 20264 min read

Debugging React Hooks: Solving Stale State with exhaustive-deps

Stop chasing mysterious bugs. Learn how the exhaustive-deps linter detects stale state in React hooks and how to fix your dependency arrays properly.

reactreact hookseslintdebuggingweb developmentjavascriptFrontend

We’ve all been there: a useEffect that refuses to trigger, or worse, an event handler using state values from three renders ago. You stare at the code, everything looks "correct," but the behavior is wildly inconsistent. In my experience, these bugs almost always boil down to a misunderstanding of how the hook dependency array manages synchronization.

If you aren't running the eslint-plugin-react-hooks with the exhaustive-deps rule enabled, you're essentially flying blind. It’s the single most effective tool for catching stale state before it hits production.

Why exhaustive-deps matters

React hooks are designed to be declarative. When you declare a dependency, you’re telling React, "Run this effect whenever these specific values change." If you omit a value that the effect relies on, you create a stale closure. The effect will keep using the version of that variable from the first time the component rendered, completely ignoring any subsequent updates.

I once spent about two days debugging a search filter that seemed to ignore the user's input. The issue? I’d defined a fetchData function inside a useEffect but failed to include searchQuery in the array. The effect ran once on mount, captured the empty initial state of searchQuery, and never updated again.

How to use the linter to your advantage

Don't ignore those yellow squiggly lines in your IDE. When the linter flags a missing dependency, it’s not just being pedantic. It’s telling you that your code is out of sync with the underlying state.

Here is how you handle the most common warnings:

  1. The "Add it to the array" fix: Most of the time, the fix is as simple as adding the missing variable to the dependency array.
  2. The "Move it inside" fix: If a function only exists to be used inside a useEffect, move the function definition inside the effect. This removes it from the dependency list entirely, keeping your code cleaner.
  3. The "useCallback" fix: If you need to share that function across multiple effects or pass it to child components, wrap it in useCallback. This ensures the function identity remains stable, preventing unnecessary re-renders.

Common pitfalls: The wrong turns

We often try to silence the linter by disabling it—// eslint-disable-next-line react-hooks/exhaustive-deps. Avoid this at all costs.

I've seen developers do this to stop an "infinite loop" error. If your effect is causing an infinite loop, the problem isn't the linter—it's your logic. You’re likely updating a state variable that is also listed in that effect’s dependency array.

StrategyWhen to useResult
Move insideFunction is local to one effectClean, no deps needed
useCallbackShared function identityStable across renders
useRefFor mutable values/timersNo trigger on update
Functional updateUpdating state based on prevRemoves state from deps

Dealing with stale state in production

If you find yourself needing to read the latest state inside an effect without triggering the effect on every change, consider if you’re using the right tool. You might be better off using a functional update, like setCount(c => c + 1), which doesn't require count to be in your dependency array.

For more complex scenarios where you need to track state across components, I often look into patterns like React custom hooks: How to fix stale closures for good to encapsulate the logic. It’s also worth checking if your current approach is causing React useEffect Infinite Loop: Fix Dependency Cycle Patterns by accident.

Frequently Asked Questions

1. Is it ever okay to ignore the exhaustive-deps warning? Almost never. If you're 100% sure a variable won't change (like a static config object), move it outside the component or wrap it in useMemo. Silencing the linter is a shortcut that usually leads to technical debt.

2. How do I fix a warning for a function that changes on every render? If your function is defined in the component body, it gets a new reference every render, which triggers your effect constantly. Wrap that function in useCallback to stabilize its reference, then add it to the dependency array.

3. Does this affect performance? Adding dependencies correctly actually improves performance. It prevents effects from running when they don't need to and ensures they only execute when the actual data they depend on changes.

Next time you see that linter warning, don't just hit "disable." Take a beat to look at what data your effect is actually using. Most of the time, the solution is right there in front of you. I'm still occasionally caught out by complex dependency chains, but trusting the linter has saved me more headaches than I can count.

Similar Posts