Max Zavatyi

Hook Dependencies and Closures in React

Dec 16, 2024

I've often seen developers not passing all the required dependencies to hooks, especially useEffect. They ignore the warning and move on if it works. Then, when other developers need to modify something in the logic, it's super easy to break everything because only that developer knows why adding 3 out of 4 values as dependencies, and after some time, even that developer will forget what that mess does.

I’ve heard things like: "Do not touch those dependencies!" or "Why pass everything if it works like this?" or "I didn’t have enough time." Like, come on. If you know how it works, then it doesn’t take time to do it the right way from the beginning to avoid spending more time refactoring or even writing from scratch because it may be too hard to wrap your head around that mess!

So, ultimately, it all boils down to knowledge! Because if you know how it works, then it doesn’t take time to do it the right way.

Before I touch on why we should pass all dependencies to hooks, and some tips and tricks on how to pass fewer dependencies when possible, let's go through some basics of hooks, such as: useEffect, useMemo, and useCallback.

Basics of useEffect, useMemo and useCallback

const [count, setCount] = useState(0);

useEffect(() => {
  console.log(count);
}, []);

I think you already know what the logs will be if we click on the button to update the counter. But in case you don’t know, the log will always show 0 no matter how many times we click on the button. This happens because we have what's called a Stale closure.

NOTE: Closures capture the variables in their surrounding scope when they are created. If you don't list all dependencies in the dependency array, the hook might "close over" stale values from the time the closure was created, rather than the current values, due to how closures work in JavaScript.

Correct example

To fix that, simply add count to the dependency array

useEffect(() => {
  console.log(count);
}, [ count ]);

That was a very simple example, of course. So, let's dive into more interesting examples. Before we move on to the main topic, I will briefly explain how to correctly pass reference values as dependencies.

Bad example of passing an unstable function reference as a dependency.

const [count, setCount] = useState(0);
const [toggle, setToggle] = useState(false);

const calcResult = () => {
   console.log('Expensive calculation executed');
   return count * 10;
};

useEffect(() => {
  console.log('useEffect triggered');
  console.log('Using memoized value:', calcResult());
}, [calcResult]);

Now, when we update the count, it should produce the following logs:

  • useEffect triggered
  • Expensive calculation executed
  • Using memoized value: 10

But what if I just press the toggle button now? Well, it will trigger the same logs with Expensive calculation executed, even though we didn’t update the count.

NOTE: If you pass an unstable reference (such as a non-memoized object, array, or function) as a dependency to a useEffect, it can cause unnecessary re-runs of the effect.

So basically, in this case, the useEffect runs whenever the component's state values update, even if it doesn’t need that value and shouldn’t run. This happens because we are not passing a stable reference to the dependency array.

Correct Example of Passing an Unstable Function Reference as a Dependency

When a function depends on external values (like count), it can become unstable because it’s recreated when those values change. To handle this correctly, use useCallback to memoize the function and include its dependencies.

const [count, setCount] = useState(0);
const [toggle, setToggle] = useState(false);

const calcResult = useCallback(() => {
  console.log(count);
}, [count]); 

useEffect(() => {
  console.log('useEffect triggered');
  console.log('Using memoized value:', calcResult());
}, [calcResult]);

Memoization with useCallback ensures calcResult only changes when count updates, preventing unnecessary useEffect calls. This approach avoids stale values and ensures accurate behavior tied to count.

Memoizing Expensive Calculations with useMemo

When a function performs an expensive calculation, use useMemo to cache the result and avoid recalculating unnecessarily. This ensures performance optimization and updates only when dependencies change.

const calcResult = useMemo(() => {
  console.log('Expensive calculation executed');
  return count * 10; 
}, [count]); 

useEffect(() => {
  console.log('useEffect triggered');
  console.log('Using memoized value:', calcResult);
}, [calcResult]); 

Correct way of passing objects and arrays as dependencies

Objects and arrays are recreated on every render, causing unnecessary useEffect triggers. Use useMemo to stabilize them and prevent re-renders unless their values change.

// Stable object
const config = useMemo(() => ({ multiplier: 2, offset: 5 }), []);
useEffect(() => console.log(config), [config]);

// Stable array
const items = useMemo(() => [1, 2, 3], []);
useEffect(() => console.log(items), [items]);

This ensures effects only run when dependencies actually change.

Bad complex example and solution

useEffect(() => {
  if (
    isCheckoutOpen &&
    currentSubscription &&
    selectedSubscription &&
    !clientSecret
  ) {
    const path = currentSubscription?.plan.isFree
      ? "/endpoint1"
      : "/endpoint2";
    const id = selectedSubscription.id;
    getClientSecret({ path, id });
  }

  if (
    isCheckoutPopupOpen &&
    currentSubscription &&
    !currentSubscription?.plan.isFree &&
    !prorate
  ) {
    getProrate();
  }
}, [isCheckoutPopupOpen, isCheckoutOpen]);

⚠️React Hook useEffect has missing dependencies: clientSecret, currentSubscription, getClientSecret, getProrate, prorate, and selectedSubscription. Either include them or remove the dependency array.eslintreact-hooks/exhaustive-deps

Now, this is an example from a real project that I encountered in my career, and as you can see, it's a total mess. Sure, it does the job, but it may break easily under some conditions, or good luck changing something and being sure it works as intended, because without spending a lot of time and playing around, it's hard to understand what's going on with those dependencies. And what if we have to modify it?

By not passing all dependencies, the effect may not update on some actions, and we could see some non-up-to-date information, or the UI won't reflect the changes. Sure, it works for a specific functionality, but as we know in practice, there are many cases where things could go wrong. So, in this case, I think even passing all dependencies would still result in bad code for a few reasons:

Problems

  • There's way too much going on in this useEffect.
  • Too many checks and conditions to make the effect run when needed.
  • Too many dependencies.
  • It's better to avoid triggering API calls in useEffect.

Better solutions

  1. Even if we want to use useEffect here (which I'm not a fan of in this case), we should split the logic into two different effects to simplify it. This way, we have fewer dependencies and cases where the effect can run. Also, we should strive to pass primitive values when possible, and if we have to pass a reference, the reference should be stable (memoized).
  2. Since the code triggers API calls when the modal opens, it's better to trigger APIs when we click a button to open the modal. Before calling the API, we can perform the necessary checks and then call the required endpoint.

With the second solution, there wouldn't be a need for useEffect at all. The code would be easier to understand, and it would run exactly when needed with fewer conditions. We would save performance by not memoizing anything and not worrying about all those dependencies.

These are the solutions I can quickly suggest right away, as I didn’t write or rewrite that piece of code. So, I’m just showing it as an example of what not to do and how we could easily do better. As you can see, it wouldn’t take much time to improve the code if we understand how all this works.

NOTE: If functions or values are not dependent on the component, we can move them outside the component lifecycle, and the hooks won’t require them as dependencies, even if we use them inside those hooks. Also, when possible, move values and functions that don’t need to be reused inside the useEffect, for example, to avoid passing that function as a dependency and wasting resources memoizing it.

With that, I hope you found this article useful and that you’ll have an easier time when encountering similar cases.

Share on:

Found this article helpful? Consider buying me a coffee and sharing your thoughts – I'd love to hear from you!