Simplify those useEffects

Introduction

useEffect is one of the most important hooks but it can lead to some of the hardest to debug code. Here's what I've learned about simplifying them.

Thinking about useEffect

I usually think of useEffect as useOnChange, it will get fired at least once and then whenever the dependencies change. I found that shifting to think that the effect will fire whenever anything changes helps me think about a few things

  1. do I really want this to run every time one of it's dependencies changes?
  2. what things do I want to watch for changes and can I simplify them?
  3. what dependencies do I have that are going to change?
  4. can we simplify the set of dependencies?
  5. do we need to protect the side effect when it does run

Do we really want this to run?

When first starting with useEffect it's really easy to get unintended renders. It's very easy to lose track of things that change easily.

  • functions
  • objects
  • arrays

Reducing the number of dependencies

Ideally if the dependencies are within the function we can wrap them in a useCallback or useMemo. A lot has been written about that so I won't go into it here.

Shrinking the dependencies - Watch the important things

Since useEffect gets triggered whenever dependencies change, it's often better to reduce the dependencies to the smallest set possible. Often this can be a single boolean or state value that actually watches the dependencies and can minimize the chance of inadvertent side effect triggers.


function MyComponent({cars, people}) {
useEffect({
if (cars?.length > 0 && people?.length > 0) {
// do something
}
}, [cars, people])
return <div>...</div>
}

We're watching two arrays, but we're only interested in the length. We can simplify this by using a useMemo to watch the length of the arrays and then only watch that value or even just check the value directly.


function MyComponent({cars, people}) {
useEffect({
if(cars?.length > 0 && people?.length > 0) {
// do something
}
}, [cars?.length, people?.length])
return <div>...</div>
}

This will protect us a bit from arrays that are being recreated, but it's still not ideal. What if we just watched a boolean instead?


export function checkHasValues({ cars, people }) {
return cars?.length > 0 && people?.length > 0
}
function MyComponent({ cars, people }) {
const hasValues = checkHasValues({ cars, people }) // optionally useMemo this depending on the time it runs for
useEffect(
{
if(hasValues) {
// do something
},
},
[hasValues]
)
return <div>...</div>
}

Now we're only watching a single boolean value and we can even move the check into a function that we can test. Let's call this a boolean based useEffect.

It's quick and easy to see what will trigger the change and it's easier to test the function that is looking for the differences. Realistically we'll also have to pass in a related function to trigger when the effect changes, but it's a much smaller dependency array and as more values get passed it'll reduce the number of times the useEffect might get called. It also protects us a bit from an object out of our control triggering the useEffect unintentionally.

I've found this a good pattern to look for as the dependencies get large. For one or two it may be overkill but as the number of dependencies grows it's a great way to simplify the code.