React useEffect and objects as dependency – 4 approaches to avoid unnecessary executions

Rmag Breaking News

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.

Table Of Contents

The problem
Approach 1: Spread object values
Approach 2: Manually pass each value
Approach 3: Third-party useDeepCompareEffect hook
Approach 4: Stringifying the object
Summary

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 usedeepcompareeffect

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.

Leave a Reply

Your email address will not be published. Required fields are marked *