The exhaustive-deps ESLint rule warns when a useEffect hook references variables or functions that are not included in its dependency array. This can cause stale closures where your effect uses outdated values from previous renders, leading to subtle bugs. Understanding and properly managing dependencies ensures your effects run with current values and behave predictably.
The react-hooks/exhaustive-deps rule is part of the eslint-plugin-react-hooks package that helps prevent stale closure bugs in React hooks. When you use useEffect, React runs the effect function after rendering. If your effect references props, state, or other values from the component scope, it "closes over" those values. If those values change but you do not include them in the dependency array, your effect will continue using the old values from the previous render instead of the new ones. The ESLint rule detects this situation and warns you to either add the missing dependency to the array or refactor your code to remove the dependency. This rule is critical because stale closures are one of the most common sources of bugs in React applications using hooks.
Read the ESLint error message carefully. It tells you exactly which variable is missing from the dependency array. For example: "React Hook useEffect has a missing dependency: 'userId'. Either include it or remove the dependency array." Locate the useEffect in your code and identify how the variable is being used inside the effect.
// ESLint will warn about missing 'userId' dependency
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, []); // userId is missing here
return <div>{user?.name}</div>;
}The most straightforward fix is to add the missing variable to the dependency array. This ensures the effect re-runs whenever that value changes, using the latest value each time.
// CORRECT - include userId in dependencies
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // Now re-runs when userId changes
return <div>{user?.name}</div>;
}If a function or variable is only used inside the effect and does not need to be shared with other parts of the component, move it inside the useEffect. This removes the dependency requirement entirely.
// BEFORE - handleClick is a dependency
function MyComponent() {
const handleClick = () => {
console.log('clicked');
};
useEffect(() => {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [handleClick]); // handleClick recreated every render
}
// AFTER - no external dependencies
function MyComponent() {
useEffect(() => {
const handleClick = () => {
console.log('clicked');
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []); // No dependencies needed
}If a function is used in multiple places and cannot be moved inside useEffect, wrap it in useCallback with its own dependencies. This ensures the function identity remains stable unless its dependencies change.
function SearchComponent({ searchTerm, apiKey }) {
// Memoize function so it only changes when dependencies change
const performSearch = useCallback(() => {
return fetch(`/api/search?q=${searchTerm}&key=${apiKey}`);
}, [searchTerm, apiKey]);
useEffect(() => {
performSearch().then(data => console.log(data));
}, [performSearch]); // Now safe to include
}When updating state based on previous state, use the functional form of setState. This removes the need to include the state variable in dependencies.
// WRONG - count must be in dependencies
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // Uses stale count
}, 1000);
return () => clearInterval(timer);
}, []); // Missing count dependency
}
// CORRECT - functional update does not need count in dependencies
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // Always uses current count
}, 1000);
return () => clearInterval(timer);
}, []); // No dependencies needed
}If you need to access a value inside an effect but do not want the effect to re-run when it changes, use useRef. The ref's current property can be updated without causing re-renders or requiring the ref itself in the dependency array.
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const latestCallback = useRef(null);
// Update ref without triggering effect
latestCallback.current = (msg) => {
setMessages(prev => [...prev, msg]);
};
useEffect(() => {
const socket = connectToRoom(roomId);
socket.on('message', (msg) => {
latestCallback.current?.(msg); // Always uses latest callback
});
return () => socket.disconnect();
}, [roomId]); // Only roomId in dependencies
}If a value never changes, move it outside the component entirely. This removes it from the dependency requirement and improves performance.
// BEFORE - DEBOUNCE_MS is inside component
function SearchBox() {
const DEBOUNCE_MS = 300;
useEffect(() => {
const timer = setTimeout(() => performSearch(), DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [DEBOUNCE_MS]); // Unnecessary dependency
}
// AFTER - constant moved outside
const DEBOUNCE_MS = 300;
function SearchBox() {
useEffect(() => {
const timer = setTimeout(() => performSearch(), DEBOUNCE_MS);
return () => clearTimeout(timer);
}, []); // DEBOUNCE_MS not needed
}After making changes, verify that the ESLint warning is gone and your effect behaves correctly. Test that your component updates properly when dependencies change. Run your test suite to ensure no regressions were introduced. If you still see infinite loops or unexpected behavior, review your dependency array for objects or arrays that get recreated on every render.
The exhaustive-deps rule is often disabled by developers who find it annoying, but doing so removes an important safety net. Stale closures are extremely difficult to debug because the symptoms are subtle and inconsistent. Instead of disabling the rule, invest time in understanding why it exists and how to properly satisfy it. If you encounter infinite loops after adding a dependency, the root cause is usually that the dependency (often an object or array) is being recreated on every render. Fix this by memoizing the value with useMemo or useCallback. For objects passed as props, consider whether you can pass primitive values instead or use React.memo with a custom comparison function. In cases where you genuinely need to suppress the warning (very rare), use the eslint-disable-next-line comment and document why it is safe. Never disable the rule globally. The React team maintains that satisfying the exhaustive-deps rule is always possible with proper refactoring. Tools like the React DevTools Profiler can help identify unnecessary re-renders caused by unstable dependencies. When working with external libraries that provide callbacks or event handlers, consider using refs to access the latest version without including them in dependencies.
React Hook useCallback has a missing dependency: 'variable'. Either include it or remove the dependency array react-hooks/exhaustive-deps
React Hook useCallback has a missing dependency
Cannot use private fields in class components without TS support
Cannot use private fields in class components without TS support
Cannot destructure property 'xxx' of 'undefined'
Cannot destructure property of undefined when accessing props
useNavigate() may be used only in the context of a <Router> component.
useNavigate() may be used only in the context of a Router component
Cannot find module or its corresponding type declarations
How to fix "Cannot find module or type declarations" in Vite