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.
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:
- 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.
- 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. - 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.
| Strategy | When to use | Result |
|---|---|---|
| Move inside | Function is local to one effect | Clean, no deps needed |
| useCallback | Shared function identity | Stable across renders |
| useRef | For mutable values/timers | No trigger on update |
| Functional update | Updating state based on prev | Removes 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.