React useEffect and objects as dependency4 approaches to avoid unnecessary executions

Johannes KettmannPublished on 

React’s useEffect hook can lead to tricky situations. If you’re not careful it can cause unnecessary executions of the effect or even infinite re-renders. Especially when using objects as dependencies.

In this blog post, you'll see four different approaches to using an object as a useEffect dependency. All have their pros and cons, from being simple yet ineffective, to ugly yet efficient.

The problem

Let me walk you through a simplified example I encountered while working on a coding task.

function useGetProducts(filters: Record<string, string>) {
useEffect(() => {
syncFilters(filters);
}, [filters]);
// ... rest of the hook
}
function ProductList() {
const products = useGetProducts({ brand: "Nike", color: "red" });
return (
<div>
{products.map((p) => (
<ProductCard key={p.id} {...p} />
))}
</div>
);
}

Now, at first glance, this looks all fine. However, using the params object as a dependency of the useEffect is problematic.

Can you see it?

Have a look at how we pass the filters object from the ProductList component to theuseGetProducts hook.

function ProductList() {
const products = useGetProducts({ brand: "Nike", color: "red" });
...

During each render, this object is created from scratch. The useEffect internally compares the dependencies by reference. And since the reference to the filters object is different for each render, the effect would be run with every render as well.

This is less than ideal, but not that easy to spot.

Approach 1: Spread object values

A simple solution is to spread all values of this object as dependencies.

function useGetProducts(filters: Record<string, string>) {
useEffect(() => {
syncFilters(filters);
}, [...Object.values(filters)]);
// ... rest of the hook
}

Technically, this isn't problematic, because the effect is run whenever one of the values of the params object changes. But we get a lint warning, and would have to disable it to commit our code.

While disabling warnings isn't necessarily a huge problem, this could lead us to forget about a missing dependency later if we, for example, add another filter. This could then lead to a hard-to-find bug.

Approach 2: Manually pass each value

Another approach is to destructure each filter and pass it as a separate dependency.

function useGetProducts(filters: Record<string, string>) {
const { brand, color } = filters;
useEffect(() => {
syncFilters({ brand, color });
}, [brand, color]);
// ... rest of the hook
}

But this is a bit tedious, and we might forget to add a new parameter if new filters are introduced. Also, in our case, this doesn’t really work as the filters object could contain any string as key.

Approach 3: Third-party useDeepCompareEffect hook

Another approach is using this useDeepCompareEffect created by Kent C. Dodds. This hook is similar to the native useEffect, but instead of comparing the dependencies by reference, it makes a deep comparison of all values inside an object.

Let's give it a try. First, we install the dependency.

npm i use-deep-compare-effect

Then, we replace the useEffect with a new useDeepCompareEffect hook. We can now simply pass the filters object as a dependency, and the effect won't be run on every render anymore.

function useGetProducts(filters: Record<string, string>) {
useDeepCompareEffect(() => {
syncFilters(filters);
}, [filters]);
// ... rest of the hook
}

The problem with this hook? When we remove a required dependency like the params object, we don't get a lint warning about missing dependencies.

So, this isn't really better than our destructuring approach before.

Approach 4: Stringifying the object

A final approach that I saw Dan Abramov recommend somewhere is stringifying the object and parsing it again inside the useEffect.

function useGetProducts(filters: Record<string, string>) {
const json = JSON.stringify(filters);
useEffect(() => {
const filters = JSON.parse(json);
syncFilters(filters);
}, [json]);
// ... rest of the hook
}

This works well with small and not too deeply nested objects that don't contain function values. Honestly, it doesn't look that great, but combines all of the advantages: It decreases the risk of forgetting to add filters and the risk of forgetting to add a dependency in the future. All while keeping the ESLint check intact.

The main problem with this approach is that we lose the type of the filters object inside the useEffect.

So inside the useEffect we need to manually assign the type again.

function useGetProducts(filters: Record<string, string>) {
const json = JSON.stringify(filters);
useEffect(() => {
const filters: Record<string, string> = JSON.parse(json);
syncFilters(filters);
}, [json]);
// ... rest of the hook
}

Summary

A useEffect in React can be tricky. Especially when you need to use an object as a dependency. In this blog post we covered 4 techniques to avoid unnecessary executions of the effect by

  • spreading the object values
  • manually adding the values
  • using the third-party useDeepCompareEffect
  • stringifying the object.

All approaches have their pros and cons and it depends on the situation which one makes most sense for you.

You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.
Improve your health as a software developer
After years working in front of a screen, my health was at an all-time low. I gained weight, my back hurt, and I was constantly tired. I knew I had to make a change. That's why I started Office Walker. I want to help you improve your health and productivity as a software developer.