Monday August 26th, 2019

ReactJS useEffect and exhaustive-deps

If you’ve used useEffect before, you’ve probably come across a "missing dependency" error at some point.

It will look something like this:

React Hook useEffect has a missing dependency: 'myFunction'. Either include it
or remove the dependency array.eslint(react-hooks/exhaustive-deps)

But if you follow the advice in the warning and add the function to the dependency list, your app suddenly starts re-rendering infinitely! What's going on?

Here’s an example taken from a project I'm working on that uses the pattern popularised by Kent C. Dodds Application State Management with React blog post:

export const App = () => {
  const { fetchClients } = useAppContext()
  useEffect(() => {
    fetchClients()
  }, [])
  return <ClientListPage />
}

This is as simple as it gets. All I want to do is invoke fetchClients once on the initial render which is why I set the dependency list to be []. But React complains that fetchClients is not in the dependency list. This initially confused me as I thought the dependency list was for variables only, but what does it mean to put a function in there? Turns out, it’s all down to the way Referential Equality works in JavaScript. Every time the App component is rendered a new instance of fetchClients is created inside useAppContext:

export function useAppContext() {
  //...

  const [state, dispatch] = context
  const fetchClients = async () => {
    await fetchClientsAction(dispatch)
  }

  //...
}

What I need to do is ensure the same instance of the function is returned each time this code is invoked. Thankfully, React comes with a built-in hook that does exactly that:

const fetchClients = React.useCallback(async () => {
  await fetchClientsAction(dispatch)
}, [dispatch])

This is saying to React: “always give me the same function instance unless the contents of the dependency list changes”. The dispatch function is guaranteed by React to be stable which is just a fancy way of saying useReducer will always return the same instance of the function.

So now - back in the App component - I can safely add fetchClients to the dependency list:

useEffect(() => {
  fetchClients()
}, [fetchClient])

This works because the identity of fetchClients doesn't change thanks to useCallback. The same instance of the function is always being returned by useAppContext, so it's the equivalent of invoking fetchClients once when the component mounts, which is exactly what I was expecting it to do in the first place!