Things I've Learned About useEffect

Published: Mon Feb 07 2022

Out of the builtin hooks, useEffect is probably the trickiest to 'get right'. And since most apps run some component side effects, there's more often than not a need for useEffect (or something built atop it). The problem is that useEffect often comes with undesirable side effects of its own when improperly used or managed.

Here are notes on some of the key things I've learned and opinions that I've developed about useEffect:

Prefer small useEffects, abstract to custom hooks when necessary

I like to follow a couple of heuristics here:

  • avoid writing useEffect when possible, deferring to libraries
  • singular responsibility — one effect per hook
  • if a component runs multiple effects, create appropriately named custom hooks for each effect

Being aware of when and how useEffect runs

Whenever a component render is triggered (mount or update) useEffect runs after react has done:

  1. rendering the component
  2. updated the DOM
  3. the browser has painted the resulting screen

So, if during rendering a value in the component state depends on some operations in useEffect to be defined, the rendering will result in an error.The following is a minimal code example that demonstrates this:

function App() {
  const [user, setUser] = useState();

  // runs last
  useEffect(() => {
    new Promise(() => {
      setTimeout(() => {
        setUser({ id: 1 });
      });
    });
  }, []);

  // TypeError: Cannot read properties of
  // undefined (reading 'id')
  // return <>{user.id}</>;

  // runs 1st
  // user is undefined until useEffect has run
  return <div>{user?.id}<div/>;
}

How useEffect differs from useLayoutEffect

They essentially do the same thing (and have the same signature), but differ in when they're fired. Instead of waiting for the browser to paint (update) the screen, useLayoutEffect runs immediately after React is done making updates to the DOM.

In practice, this means that if useEffect has effects that, for example, manipulate the styles of some DOM elements, there might be a flicker on the screen since these updates happen after visual updates have been applied.

In short, useEffect effects waits for visual updates to finish, then runs. useLayoutEffect, on the other hand, does not. The majority of effect use cases are can be taken care of with useEffect.

useEffect can be thought of in terms of effect and state synchronization

Ryan Florence provides a useful mental model that helps with reasoning about the arguments provided to useEffect:

"The question is not "when does this effect run" the question is "with which state does this effect synchronize with:
useEffect(fn) // all state
useEffect(fn, []) // no state
useEffect(fn, [these, states])"

Why async await does not work in useEffect

It's common to trip over the fact that async await does not with the callback function provided to useEffect. This is because useEffect always returns a cleanup function. Designating the function as async sets its return type as a promise which won't work due to the incompatibility.

// ❌ won't work
useEffect(async () => {
  await doSomething()
}, [])

// ✅ all good
useEffect(() => {
  await doSomething().then(updateState)
}, [])

In the second example, the return type of the callback is not modified and React won't complain.

There are gotchas with putting 'complex' items in the dependency array

With primitive values, like booleans and numbers things are relatively simple:

useEffect(() => {
  // doSomething
  // count is a boolean
}, [props.count])

However, when we place objects (inc. arrays and functions) in the array we have to be mindful of the way React compares objects in the dependency array. Whenever useEffect is run, the items in the dependency array from the previous render are compared with the items it has on the current render. React uses referential equality for objects, i.e. it checks whether the two objects point to the same object in memory. This way, two objects can be equal in their 'shape' or content, but be referentially unequal.

Here's an example:

// user has the shape {id: string, name: string}

useEffect(() => {
  // accessing specific properties of user
  if (user.id) {
    // doSomething
  }
}, [props.user])

The problem with the example above is that there is no guarantee that the user object (passed through props) will be the same object on the next render. The useEffect only uses the id, but depends on the whole object the other parts of which may change. A better way to handle this would be to depend only on the individual primitive properties that the effect actually depends on, like so:

// user has the shape {id: string, name: string}

useEffect(() => {
  // accessing specific properties of user
  if (user.id) {
    // doSomething
  }
  // now the name property can freely change without
  // causing a re-render
}, [props.user.id])

Another common issue is with function declarations that change between renders. Something like this is extremely common:

function Component() {
  const [user, setUser] = useState({
    id: 1,
    name: 'name',
    active: false,
  })

  // this function is redeclared on each render
  // it's a contrived example
  const updateUser = () => {
    setUser({ ...user, name: 'new' })
  }

  useEffect(() => {
    if (user.active) updateUser()
  }, [updateUser])

  // return some UI
}

We get the following warning (if using the hooks eslint plugin):

The 'updateUser' function makes the dependencies of useEffect Hook (at line x) change on every render.
Move it inside the useEffect callback. Alternatively, wrap the 'hideMessage' definition into its own useCallback() Hook. eslint(react-hooks/exhaustive-deps)

The reason for this is that the updateUser function is recreated on every render and is not (referentially) equal to the updateUser function from the previous render:

let obj1 = { id: 1 }
let obj2 = { id: 1 }

// evaluates to false — same behaviour, different reference
obj1 === obj2

And since the function is always different, the dependencies of the useEffect change every render just as the warning says.

There's a couple of ways of solving these types of issues:

  1. Move the function definition inside useEffect to eliminate it as dependency
  2. Move the function outside the component if possible
  3. Wrap the function definition into its own useCallback() hook

The first is the simplest and the one I prefer in most cases. It's a sensible default to have.

Exercising care when calling setstate inside useEffect

Infinite rendering loops and stale closures are common with useEffect. The issue stems from useEffect depending on some previous value of state.Many, if not most, of these cases can be avoided by using the functional form of setState (accessing the previous state value in the callback).

Infinite loops occur when useEffect is synchronised with some piece of state, that state is updated in the useEffect, which triggers the component to re-render, which in turn causes useEffect to run again, which updates that same state and so on.

Here's an example of infinite loop caused by a useEffect dependency:

function App() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    // infinite loop
    setCount(count + 1)
  }, [count])

  // return some UI
}

A better (and working) way:

function App() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    // use previous state directly from the callback
    setCount((count) => count + 1)
    // no more dependency on count
  }, [])

  // return some UI
}

This way we are guaranteed to access the freshest value of the piece of state that we depend on inside useEffect.

Let libraries take care of the harder parts of useEffect

useEffect was always intended as a low-level primitive to be used as a building block for things like useFetch or useSubscription. However, most people use it directly and very often end up running into issues and then writing hairy boilerplate to manage issues with async operations or stale closures. It takes a fair amount of work to get it right. Also, as a built-in primitive, useEffect does not come with any other batteries, such as cache management.

I prefer to use well-vetted and battle tested libraries for custom hooks and data fetching. A great collection of essential custom hooks I often turn to is provided by react-use. For data fetching, react-async is a lightweight option, and react-query or SWR do well for more involved use cases.

Thoughts

In conclusion, I haven't been particularly fond of the useEffect API — it's made me think a lot about things that I'd personally prefer to be taken care of by a bit more magic. In any case, when suspense for data fetching is stable, most of the major pain points will (hopefully) be problems of the past.