React Dependency Arrays

Daniel Wolf Jul 23rd, 2024

If you’ve worked on a React project in the last 5 years, you’ve seen how hooks can simplify your code and also introduce complexity you may not fully understand. You’ve probably encountered warnings from your editor or ESLint about missing dependencies in dependency arrays like this:

const handleIncrement = useCallback(
  () => setCount(count),
  // React Hook useCallback has a missing dependency: 'count'. Either include it or remove the dependency array.
  []
);

In the past I've been guilty of simply adding the dependencies that it asks me to add and moving on, but this is probably a bad idea. These dependencies might be arrays, objects, or simply values that change on every render, and so the code inside the hook may run on every render. After learning the error of my ways, I wrote this guide to help you understand when, why, and how to include dependencies in these arrays, ensuring your code runs efficiently and correctly.

Understanding React Dependency Arrays

In React, functional components re-run every time the component’s props or state change. This is the “reactivity” that we all love. When the inputs change, the UI changes. While this is useful for ensuring the UI stays up-to-date, we don’t always want the code inside our hooks to run on every change. This is where dependency arrays come in. They allow us to control when the code inside hooks should re-run based on specific changes in dependencies.

Dependency arrays are a crucial aspect of hooks like useEffect, useMemo, and useCallback. They determine when the code inside these hooks should be re-executed based on changes to the specified dependencies.

Have your dependencies changed?

React uses Object.is() for comparing dependencies to see if they have changed. This method is similar to the strict equality operator (===), but it has some differences in how it treats special cases like NaN and -0. Understanding Object.is() helps in grasping when React decides to re-run the code inside hooks. React uses Object.is() to perform a shallow comparison of the dependencies in the array one by one. If any dependency has a new reference according to Object.is(), the hook’s code runs again.

Empty Dependency Arrays

Using an empty dependency array in React hooks like useEffect, useCallback, or useMemo might seem convenient for running code only once, but it can lead to significant issues. The hook will not re-run on updates to props or state, potentially causing missed updates and stale closures, where the captured values remain outdated. This can result in inconsistent behavior and make the component harder to debug and maintain. Additionally, relying on this pattern can complicate future refactoring and lead to optimization trade-offs that degrade the application’s performance and responsiveness. Explicitly managing dependencies is crucial for creating reliable and maintainable React components.

Best Practice for Using Dependency Arrays

Ask your self these questions before putting something in the dependency array:

  • If this variable changes, do I want my code to rerun?
  • If this variable changes, would it affect how the code inside runs?
  • Is this variable a primitive?
  • If it's not a primitive, will the reference in this variable change on every render?

Why does my useEffect or useMemo run on every render?

First of all, re-renders are usually a good thing. I would rather have too many renders than miss a render when data has actually changed. Try not to over-optimize. That said, if you know your dependencies aren't being respected, here are some examples to help you diagnose your issue.

Is your dependency an array?

Consider using the array length in your dependency array. That way it will only run if the length of the array changes. Bonus: the length is a primitive.

const [items, setItems] = useState([{ id: 1, name: 'Item 1' }]);
const itemsLength = useRef(items.length);

useEffect(() => {
  if (items.length !== itemsLength.current) {
    console.log('Effect ran because items array length changed');
    itemsLength.current = items.length;
    // Imagine some side-effect here like fetching data, updating state, etc.
  }
}, [items.length]); // <-- SEE HERE

const addItem = () => {
  setItems([...items, { id: 2, name: 'Item 2' }]);
};

Is your dependency an object?

Consider choosing a property on that object that is a primitive and represents a change in the entire object. Or, just use each property of the object in the depency array.

const [user, setUser] = useState({ id: 1, name: 'John Doe' });
const previousUser = useRef(user);

useEffect(() => {
  if (user.name !== previousUser.current.name || user.id !== previousUser.current.id) {
    console.log('Effect ran because user object properties changed');
    previousUser.current = user;
    // Imagine some side-effect here like fetching data, updating state, etc.
  }
}, [user.name, user.id]); // <-- SEE HERE

const updateUserName = () => {
  setUser(prevUser => ({ ...prevUser, name: 'Jane Doe' }));
};

Are you using React Query?

If you want to put the result of a useQuery call in your dependency array, don't put the entire result. You can use the data property if you want the it to run whenever the query is updated from the server, while cached results will not re-render. Or, you can use a property from the API response to know when the data has changed. Make sure this property is a primitive.

const queryInfo = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos
})

const processedData = useMemo(
  () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
  [queryInfo] // <-- DON'T DO THIS, CHANGES EVERY TIME
);

const processedData = useMemo(
  () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
  [queryInfo.data] // <-- CHANGES WHEN THE API IS HIT, NOT CACHED RESULTS
);

const processedData = useMemo(
  () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
  [queryInfo.data.timestamp] // <-- CHANGES WHEN THE API TELLS YOU THE DATA IS CHANGED
);

When all else fails, use JSON.stringify

If you can stringify your dependency variable, you can turn it into a string primitive. This removes the issue of whether the variable is the same reference.

const [user, setUser] = useState({ id: 1, name: 'John Doe' });
const previousUser = useRef(user);

useEffect(() => {
  if (user.name !== previousUser.current.name || user.id !== previousUser.current.id) {
    console.log('Effect ran because user object properties changed');
    previousUser.current = user;
    // Imagine some side-effect here like fetching data, updating state, etc.
  }
}, [JSON.stringify(user)]); // <-- SEE HERE

const updateUserName = () => {
  setUser(prevUser => ({ ...prevUser, name: 'Jane Doe' }));
};

Handling Editor Warnings

You may already know that you can add an inline comment that will tell your linter to ignore the warning for the next line. I would also suggest a comment explaining why you've decided to ignore it. This will encourage other developers to look closely at those dependencies when working on the code later. Do this by adding two dashes after the comment and then add your reasoning.

useEffect(() => {
	...
	// eslint-disable-next-line react-hooks/exhaustive-deps -- this code should not run if the value of X changes
}, []);

Common Mistakes

  • Primitive vs. Complex Dependencies: Remember that even if the content of an object or array hasn’t changed, a new reference will mean React treats it like a change in a dependency.
  • Inline Functions and Objects: Defining functions or objects inline within the hook can cause unnecessary re-renders. Use useCallback or useMemo to memoize them.
  • Incorrect or Missing Dependencies: Always include all necessary dependencies. If a variable or function used inside the hook can change, it should be in the dependency array.
  • Over-Relying on Empty Dependency Arrays: While an empty dependency array ensures the effect runs only once (on mount), it can lead to bugs if dependencies are overlooked.

Understanding and correctly using dependency arrays in React hooks is vital for creating efficient and bug-free applications. By carefully considering which dependencies to include and following best practices, you can avoid common pitfalls and ensure your code runs as intended. Experiment with these concepts and integrate them into your projects to see the benefits firsthand.

Learn More