Understanding React Hooks and Dependencies

In addition to useState, there are three React hooks that you will regularly use: useEffect, useCallback, and useMemo. While the usage scenarios of these hooks differ, they all have some shared concepts and rules that are important to understand.

The useEffect hooks allows a function to be executed in response to value changes. The useCallback hook is used to memoize a (callback) function, which creates a “stable” reference that can be safely passed to other components and hooks. Likewise, useMemo is used to memoize a value by caching computed results. I’ll be going over all of these in detail.

useEffect

This hook takes two arguments: a function and an array of dependencies. The function will be called on first-time render and when any of the values of each dependency changes:

useEffect(() => {
  console.log("useEffect function called");

  // ...called whenever any of these items change
}, [someVar, someObj]);

The dependency array is a list of values that the function you passed to useEffect depends on. It can be read as “trigger this function when any of these values change” (the initial render is considered a “change” since there were no previous values).

React will check dependencies on each render with a shallow comparison, comparing each item in the array to the previous value using a strict equality check. I’ll dig deeper into this soon because there are some nuances in regards to the values that you need to be aware of when including items in this array.

Run on first render only

If you pass an empty array as the dependencies argument in useEffect, the function will only be run on the first render. This is similar to the behavior of the componentDidMount lifecycle method of class-based components.

useEffect(() => {
  console.log("Only run on first-time render").

  // empty dependencies argument
}, []);

Naturally, you might assume that omitting the second argument altogether would mimic this behavior but this is not the case. If you do not pass a second argument, the function will be called on every render! This is a common mistake made by new React developers.

useEffect(() => {
  console.log("Warning! This code is executed on every render");

  // missing dependencies argument (not the same behavior as empty [])
});

Executing code on dismount

The function you pass to useEffect can optionally return another function that will be executed when the component dismounts. It is commonly (and should be) used to clean up timeout references, event listeners, etc. that are managed by this component.

useEffect(() => {
  const onFocus = () => {
    console.log("Window focused.");
  };
  window.addEventListener("focus", onFocus);

  // function to be called when component is dismounted
  return () => {
    window.removeEventListener("focus", onFocus);
  };
}, []);

Stable and volatile values

Before I explain the other hooks, it’s worth going over the concept of stable versus volatile values in React because this is a common source of issues when it comes to unnecessary re-renders and hook functions that are unintentionally executed.

It’s easy to think that functional components in React are special, but on a technical level they are not. They have some requirements to be considered a component1, but functional components are simply that: functions. Whenever it is rendered, the function/component is called. And just like any other JavaScript function, any objects (or functions) defined directly within this function are brand new instances. Take the following component for example:

const MyComponent = () => {
  const person = {
    name: "John Doe",
  };

  return <div>Name: {person.name}</div>;
};

Whenever MyComponent is rendered, the person local variable is assigned a new object reference. This object is volatile because it is instantiated (created) on every render (which is every time the MyComponent function is called by React).

The previous example isn’t too problematic in and of itself, but if you were to use this object as a dependency of a hook such as useEffect, the hook’s function would be called on every render because being a new object, it can never strictly equal the previous value:

const MyComponent = () => {
  // new object instantiated here
  const person = {
    name: "John Doe",
  };

  useEffect(() => {
    console.log("Called on every render");

    // 'person' is re-instantiated on every render so this
    // effect is run every time MyComponent is rendered
  }, [person]); // <-- new object does not "equal" previous render's object

  return <div>Name: {person.name}</div>;
};

Recommendation: Make sure your linter is set up properly to warn you about code like this. It’s a surprisingly easy mistake to make!

Alternatively, a stable value is one that persists between renders. To avoid using volatile values as dependencies to hooks or passing them as props to memoized components2, you can make a non-primitive3 value stable by storing it in component state with useState, or the other two hooks I’ll be going over next.

Here’s how you could fix the previous example by making person stable with useState:

const MyComponent = () => {
  // new object created and stored as state; will persist between
  // renders until setPerson() is called
  const [person, setPerson] = useState({
    name: "John Doe",
  });

  useEffect(() => {
    console.log("The person changed.");

    // 'person' is stable so this effect is only run on initial
    // render and when setPerson() is called
  }, [person]); // <-- state value

  return (
    <button
      onClick={() => {
        // changing the `person` object by calling setPerson()
        setPerson({
          name: "Jane Doe",
        });
      }}
    >
      Name: {person.name}
    </button>
  );
};

useMemo

Just as objects that are defined directly within a function are instantiated as new on every render, so is any logic or computations that are included within a function. Because your app likely consists of many components, it is recommended you cache any heavy computations (also known as memoization), and any that depend on other state (or memoized) values.

A good practice is to try your best to prevent code that does not strictly need to run on each render from doing so.

Here’s a basic example without memoization:

import format from "date-fns/format";

const MyComponent = () => {
  const [date, setDate] = useState(new Date());

  const formattedDate = format(date, "MMM dd, yyyy");

  return <div>The date is {formattedDate}</div>;
};

In the above example, the result of formattedDate is evaluated on every render, even though date is a stable value that does not change unless setDate() is called. This example is simple to illustrate the point, but imagine if the computation was heavier, or if the component is rendered as part of a large list of items. Ask yourself, does format() really need to be called every time this component is rendered?

It would be better to cache/memoize this computation so that it is only re-evaluated when date changes. You can do this with the useMemo hook:

import format from "date-fns/format";

const MyComponent = () => {
  const [date, setDate] = useState(new Date());

  const formattedDate = useMemo(() =>
    format(date, "MMM dd, yyyy"),
    [date],
  );

  return <div>The date is {formattedDate}</div>;
};

Just like useEffect, the useMemo hook takes two arguments: a function and an array of dependencies. The difference is that the return value of the function is what will be assigned to the variable (the result of the computation). In this case, the result of the format() function (from the date-fns library) is memoized and assigned to the formattedDate variable. If the return value is an object or function, the memoized result will be stable.

Also like useEffect, if you omit the dependencies array, the function will be called on every render (effectively making the memoization useless). Likewise, you should only pass either primitive values3 or other stable values as dependencies. Again, your linter should be set up to prevent you from making this mistake.

useCallback

The useCallback hook also takes two arguments in the form of a function and a dependencies array. The function you specify can take arguments, be asynchronous, return values, etc. Except for the dependencies array, it is exactly the same as defining your own custom function.

const updateState = useCallback(
  (newValue) => {
    setSomeState(newValue);
  },
  [someVar]
);

// later on in your code:
updateState(someValue);

So why would you use this instead of just defining a function directly in the component itself? If you’ve been following along so far, you can probably guess: the resulting function will be stable and thus able to be safely used as a dependency to another hook or as a prop to a component that’s been memoized with React.memo.

However, if you are not going to use the function in the above two cases (as a dependency or as a prop to a memoized component), then wrapping a function in useCallback provides no benefit and can be safely be avoided.

Summary

By now you should have a better understanding of some of the most important built-in hooks that React provides, the scenarios in which you would use each one, how hook dependencies work, and the difference between stable and volatile values in React. If you take the time to fully understand these concepts and use these hooks where appropriate, your React apps will have fewer bugs and better performance.

For further reading, I recommend the official React hooks documentation as it always provides the most up-to-date information, and you can also learn about other built-in hooks that you may find useful.


  1. A functional React component can optionally take a single argument (a props object) and must return a React element. This is the only thing that separates a React component from any other JavaScript function. ↩︎

  2. See the React.memo docs for more info on memoizing components to prevent unnecessary re-renders. ↩︎

  3. Strings, numbers, booleans, undefined, null, and symbols are primitive values and are inherently stable in React. Objects (including arrays) and functions are not primitives. ↩︎ ↩︎