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.

Additional methods

Edit 2025 - included additional methods:

  • latest ref pattern https://www.epicreact.dev/the-latest-ref-pattern-in-react
  • useEffectEvent (experimental in react 18 and stable in 19) instead
  • perform actions in response to the event (user click etc)
const [value, setValue] = useState(0) // run in response to value change useEffect(() => { console.log(`Value is ${value}`) }, [value]) return <Button onClick={() => setValue(value + 1)}>Increment {value}</Button> // change instead be run in response to an event const [value, setValue] = useState(0) return ( <Button onClick={() => { const incrementedValue = value + 1 setValue(incrementedValue) // run in response to a side effect console.log(incrementedValue) }} > Increment {value} </Button> ) // or as a callback where we receive current state at the time of the event const incrementValue = useCallback((value) => { const incrementedValue = value + 1 setValue(incrementedValue) // run in response to a side effect console.log(incrementedValue) }, []) return ( <Button onClick={() => { incrementValue(value) }} > Increment {value} </Button> )