React Hooks provide a powerful way to manage side effects in functional components using the useEffect
hook. However, many developers encounter issues where the exhaustive deps rule doesn’t seem to work correctly or produces unexpected behavior. This can lead to stale state, unnecessary re-renders, or even infinite loops.
In this guide, we will explore why exhaustive dependencies (deps) might not work, common mistakes, and the best practices to resolve them.
What is useEffect
and Exhaustive Dependencies?
The useEffect
hook in React allows developers to perform side effects like API calls, subscriptions, or DOM manipulations. It accepts a dependency array that determines when the effect should re-run.
Basic Syntax
useEffect(() => {console.log("Effect runs");});
By default, the effect runs after every render. To control its behavior, we use the dependency array.
Using the Dependency Array
useEffect(() => {console.log("Runs only once");}, []); // Empty dependency array
If we include dependencies, the effect runs whenever those values change:
const [count, setCount] = useState(0);useEffect(() => {console.log(`Effect runs when count changes: ${count}`);}, [count]);
React enforces a rule called exhaustive-deps, which warns developers when they forget dependencies that should be included in the array.
Why is useEffect
Exhaustive Deps Not Working?
1. Ignoring ESLint Warnings
One of the main reasons developers face issues with exhaustive deps is ignoring ESLint warnings. React provides linting rules that help identify missing dependencies. If you disable or ignore these warnings, your effect might not behave as expected.
Example of a Common Warning
const [count, setCount] = useState(0);useEffect(() => {console.log(count); // Stale value}, []);
Warning: React Hook useEffect has a missing dependency: ‘count’.
Fix: Include count
in Dependencies
useEffect(() => {console.log(count);}, [count]);
2. Mutating Dependencies Inside useEffect
If you modify a dependency inside the effect, React won’t recognize the change properly, leading to unexpected behavior.
Incorrect Example
const [data, setData] = useState([]);useEffect(() => {data.push("new item"); // ❌ Mutating state directly}, [data]);
Since React does shallow comparison of dependencies, it won’t detect changes to objects or arrays if they are mutated directly.
Fix: Use State Updaters
useEffect(() => {setData((prevData) => [...prevData, "new item"]);}, []);
3. Using Functions as Dependencies Without useCallback
If you pass a function as a dependency, React recreates the function on every render, causing unnecessary re-executions of the effect.
Incorrect Example
const fetchData = () => {console.log("Fetching data...");};useEffect(() => {fetchData();}, [fetchData]); // ❌ Function gets re-created
Fix: Wrap Function in useCallback
const fetchData = useCallback(() => {console.log("Fetching data...");}, []);useEffect(() => {fetchData();}, [fetchData]); // ✅ Stable dependency
4. Using Objects or Arrays as Dependencies
React performs shallow comparison on dependency arrays. If an object or array is re-created on every render, the effect will keep re-running.
Incorrect Example
const [count, setCount] = useState(0);useEffect(() => {console.log("Effect runs");}, [{ count }]); // ❌ Always re-runs (new object on each render)
Fix: Use Primitive Values or Memoization
useEffect(() => {console.log("Effect runs");}, [count]); // ✅ Primitive value
Or, if using an object, wrap it in useMemo
:
const data = useMemo(() => ({ count }), [count]);useEffect(() => {console.log("Effect runs");}, [data]); // ✅ Memoized object
5. Depending on Props That Change Frequently
If a prop value changes on every render, it will cause the effect to re-run constantly.
Incorrect Example
const MyComponent = ({ items }) => {useEffect(() => {console.log("Effect runs");}, [items]); // ❌ `items` may change frequently};
Fix: Memoize Props Using useMemo
or useCallback
const MyComponent = ({ items }) => {const memoizedItems = useMemo(() => items, [items]);useEffect(() => {console.log("Effect runs");}, [memoizedItems]); // ✅ Stable dependency};
6. Using Empty Dependency Array Incorrectly
If an effect relies on state values, but you use an empty dependency array, React will not re-run the effect when those values change.
Incorrect Example
const [count, setCount] = useState(0);useEffect(() => {console.log(count); // ❌ Stale value}, []); // Will only log once
Fix: Add count
as a Dependency
useEffect(() => {console.log(count);}, [count]); // ✅ Updates correctly
How to Debug useEffect
Exhaustive Deps Issues?
✅ 1. Always Check ESLint Warnings
React’s exhaustive-deps rule is there for a reason. Follow its suggestions to avoid common pitfalls.
✅ 2. Avoid Directly Mutating Dependencies
Instead of modifying arrays or objects inside useEffect
, use state updater functions.
✅ 3. Use useCallback
and useMemo
When Necessary
Memoize functions and objects that are used as dependencies to avoid unnecessary re-renders.
✅ 4. Log Dependencies to Debug Issues
If useEffect
is not working as expected, log dependencies to check if they are changing.
useEffect(() => {console.log("Effect runs", count);}, [count]);
If your React Hooks exhaustive deps are not working, it is often due to missing dependencies, incorrect dependency tracking, or frequent re-creations of values. To ensure proper functionality:
- Follow ESLint warnings and include all necessary dependencies.
- Avoid mutating objects or arrays directly; use
useState
updates instead. - Wrap functions in
useCallback
and objects inuseMemo
to stabilize dependencies. - Check if props are changing unnecessarily and optimize them with memoization.
By applying these best practices, you can write cleaner, more efficient useEffect
hooks and prevent unexpected bugs in your React applications.