DEV Community

Cover image for React useEffect and objects as dependency - 4 approaches to avoid unnecessary executions
Johannes Kettmann
Johannes Kettmann

Posted on • Originally published at profy.dev

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

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

  1. The problem
  2. Approach 1: Spread object values
  3. Approach 2: Manually pass each value
  4. Approach 3: Third-party useDeepCompareEffect hook
  5. Approach 4: Stringifying the object
  6. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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" });
  ...
Enter fullscreen mode Exit fullscreen mode

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.

The useEffect runs on every render

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Missing dependency warning

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

No missing dependency warning with useDeepCompareEffect

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Type of filters is any

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
}
Enter fullscreen mode Exit fullscreen mode

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.

The React Job Simulator

Top comments (1)

Collapse
 
brense profile image
Rense Bakker

Option 1 should be to cleanup the dirty state with the built-in useMemo hook:

const filters = useMemo(() => ({ brand: "Nike", color: "red" }), [])
Enter fullscreen mode Exit fullscreen mode

Problem solved 😁