Programming

Understanding the React Hooks exhaustive-deps lint rule

25 September 2026 · 7 min read

Understanding the React Hooks exhaustive-deps lint rule

Developing robust and predictable React applications often hinges on understanding subtle yet powerful features of the framework. Among these, React Hooks have revolutionized state management and side effects, but they come with their own set of best practices enforced by linting rules. One particularly crucial rule, often encountered by developers, is the ’exhaustive-deps’ lint rule. This ESLint rule, part of the eslint-plugin-react-hooks package, is designed to ensure that all values used inside a Hook’s dependency array are correctly declared, preventing common bugs like stale closures and unexpected behavior. Grasping this rule isn’t just about silencing warnings; it’s about writing more reliable, performant, and maintainable React code.

Why the ’exhaustive-deps’ Rule Matters for React Developers

The ’exhaustive-deps’ rule is a cornerstone of modern React development, primarily because it addresses a fundamental challenge with Hooks: managing the closure over time. When you use Hooks like useEffect, useCallback, or useMemo, they often “capture” variables from their surrounding scope. If these captured variables change between renders, but the Hook isn’t re-executed or re-memoized, you can end up with what’s known as a “stale closure.” This means your effect or memoized function is operating on outdated values, leading to bugs that are notoriously difficult to track down.

This rule helps prevent such issues by strictly enforcing that the dependency array for these Hooks includes every value from the component scope that the Hook uses. For instance, if your useEffect hook uses a prop called userId to fetch data, userId must be in its dependency array. If userId changes, the effect needs to re-run to fetch data for the new user. Without this enforcement, your component might display data for the old user even after the prop has updated, leading to a significant disconnect between your UI and underlying data.

Beyond correctness, adhering to the ’exhaustive-deps’ rule also contributes to application performance. By ensuring that Hooks only re-run or re-calculate when their actual dependencies change, you avoid unnecessary computations and side effects. This optimization helps keep your component renders efficient and responsive, crucial for complex applications. The rule acts as a vigilant assistant, guiding developers toward more predictable and robust state management patterns.

Deconstructing the Dependency Array: How It Works

The dependency array is a second argument passed to Hooks like useEffect, useCallback, and useMemo. Its purpose is to tell React when to re-run an effect or re-memoize a function/value. When React performs a re-render, it compares the values in the dependency array from the previous render to the current render. If any value has changed (based on strict equality comparison), the Hook’s callback will execute again.

The primary purpose of the ’exhaustive-deps’ lint rule is to prevent stale closures by ensuring that all values referenced inside a Hook’s callback function, which are defined outside of that callback and could change between renders, are included in its dependency array. This guarantees that the Hook always operates with the most current version of those variables. Omitting a dependency means the Hook will continue to use the value it captured during its initial render, even if that value has since updated elsewhere in the component’s lifecycle.

Consider a useEffect hook that logs a counter. If the counter variable is not in the dependency array, the effect will only log the initial value of counter (e.g., 0) every time it runs, even if the counter has incremented. Including counter in the array ensures the effect re-runs whenever counter changes, logging its updated value. This careful management of dependencies is fundamental to the predictable behavior of Hooks.

Common Scenarios and How to Address ’exhaustive-deps’ Warnings

You’ll frequently encounter ’exhaustive-deps’ warnings in various scenarios. Resolving them usually involves one of a few strategies:

  1. Add Missing Dependencies: The most straightforward solution is to add the missing variable to the dependency array. If your effect uses props.data and it’s not in the array, simply add it. React will then correctly re-run the effect when props.data changes.
  2. Wrap Functions/Objects with useCallback/useMemo: If your effect depends on a function or an object that is re-created on every render (e.g., const handleClick = () => { ... }), it will cause the effect to re-run unnecessarily. Wrap such functions with useCallback and objects with useMemo. This ensures their reference stability, only changing when their own dependencies change, thus preventing extraneous effect re-runs.
  3. Move Variables Inside the Hook: If a variable is only used within a specific Hook and doesn’t need to be accessible outside, consider declaring it directly inside the Hook’s callback. This makes it part of the Hook’s scope and removes the need for it to be a dependency.
  4. Refactor to Reduce Dependencies: Sometimes, an effect might depend on too many variables, indicating it’s doing too much. Breaking down complex effects into smaller, more focused ones can simplify dependency management and improve readability. For instance, consider using custom hooks to encapsulate related logic and state. You can find more strategies for managing complex React state and side effects in articles like optimizing React component re-renders.

Understanding these resolution patterns is key to effectively using the ’exhaustive-deps’ lint rule and writing robust React code. It pushes developers to think critically about data flow and side effect management, leading to more predictable and easier-to-debug applications.

When to (Carefully) Ignore the Rule

While the ’exhaustive-deps’ rule is incredibly helpful, there are rare, specific scenarios where you might need to override it. This should always be done with extreme caution, as it indicates you are taking on the responsibility of manually ensuring your dependencies are correct, potentially introducing subtle bugs. A common reason for ignoring the rule might be when an effect needs to run only once on mount, but it happens to use a variable that changes frequently, and you are certain that the effect should not react to those changes.

To explicitly ignore a warning for a single line, you can use the ESLint comment: // eslint-disable-next-line react-hooks/exhaustive-deps directly above the line where the Hook is declared. For example:

useEffect(() => { // This effect uses 'someVariable' but we explicitly don't want it in
<b>Question & Answer : </b><br></br><p>I'm having a hard time understanding the 'exhaustive-deps' lint rule.</p> <p>I already read <a href="https://stackoverflow.com/questions/58549846/react-useeffect-hook-with-warning-react-hooks-exhaustive-deps">this post</a> and <a href="https://stackoverflow.com/questions/57983717/designing-react-hooks-prevent-react-hooks-exhaustive-deps-warning">this post</a> but I could not find an answer.</p> <p>Here is a simple React component with the lint issue:</p> const MyCustomComponent = ({onChange}) => { const [value, setValue] = useState(''); useEffect(() => { onChange(value); }, [value]); return ( <input value={value} type='text' onChange={(event) => setValue(event.target.value)}> </input> ) }  <p>It requires me to add onChange to the useEffect dependencies array. But in my understanding onChange will never change, so it should not be there.</p> <p>Usually I manage it like this:</p> const MyCustomComponent = ({onChange}) => { const [value, setValue] = useState(''); const handleChange = (event) => { setValue(event.target.value); onChange(event.target.value) } return ( <input value={value} type='text' onChange={handleChange}> </input> ​ ) }  <p>Why the lint? Any clear explanation about the lint rule for the first example?</p> <p><em>Or should I not be using useEffect here? (I'm a noob with hooks)</em></p>
<br></br><p>The reason the linter rule wants onChange to go into the useEffect hook is because it's possible for onChange to change between renders, and the lint rule is intended to prevent that sort of "stale data" reference.</p> <p>For example:</p> const MyParentComponent = () => { const onChange = (value) => { console.log(value); } return <MyCustomComponent onChange={onChange} /> }  <p>Every single render of MyParentComponent will pass a different onChange function to MyCustomComponent. </p> <p>In your specific case, you probably don't care: you only want to call onChange when the value changes, not when the onChange function changes. However, that's not clear from how you're using useEffect. </p> <hr></hr> <p>The root here is that your useEffect is somewhat unidiomatic. </p> <p>useEffect is best used for side-effects, but here you're using it as a sort of "subscription" concept, like: "do X when Y changes". That does sort of work functionally, due to the mechanics of the deps array, (though in this case you're also calling onChange on initial render, which is probably unwanted), but it's not the intended purpose.</p> <p>Calling onChange really isn't a side-effect here, it's just an effect of triggering the onChange event for <input>. So I do think your second version that calls both onChange and setValue together is more idiomatic. </p> <p>If there were other ways of setting the value (e.g. a clear button), constantly having to remember to call onChange might be tedious, so I might write this as:</p> const MyCustomComponent = ({onChange}) => { const [value, _setValue] = useState(''); // Always call onChange when we set the new value const setValue = (newVal) => { onChange(newVal); _setValue(newVal); } return ( <input value={value} type='text' onChange={e => setValue(e.target.value)}></input> <button onClick={() => setValue("")}>Clear</button> ) }  <p>But at this point this is hair-splitting.</p>