Skip to content

When do I use functions in a Hooks Dependency Array?


The short answer is...
whenever the linter tells you to.

And that could be fairly often.
But why does it tell you to do that sometimes and not always?


Hooks were designed to bring functional composition to React, but there are some rules that you'll need to follow in order for them to work. React has some built-in linting rules that will tell you when you're doing certain things wrong (like conditionalizing the order of hooks). There's also additional rules you need to install separately. These additional rules help you catch bugs early like helping you to know what needs to be added to your dependency array - the exhaustive-deps rule.

There's more than one hook that uses a dependency array concept, but chances are you'll learn about the dependency array when you learn useEffect which is often one of the first hooks people learn:

function ComposeMessage() {
  const [message, setMessage] = useState()
  
  // Changing the title every time the message changes is a side-effect,
  // so it needs to go in `useEffect`
  useEffect(() => {
    document.title = message
  }, [message])
  return <input type="text" onChange={e => setMessage(e.target.value)} />
}

Our effect "depends" on the message state. So if that state changes, we need to run the effect again.

Now let's save the message to local storage when the message changes. This way we can quickly recover it as a draft if it's not saved. We'll also use a uid prop intended to be the recipient's User ID:

import { saveToLocalStorage, getFromLocalStorage } from './saveToLocalStorage'

function ComposeMessage({ uid }) {
  const [message, setMessage] = useState(getFromLocalStorage(uid) || '')
  
  useEffect(() => {
    saveToLocalStorage(uid, message)
  }, [uid, message]) // our effect now depends on more things
  return (
    <input
      type="text"
      value={message}
      onChange={e => setMessage(e.target.value)}
    />
  )
}

The linter will now ask us to add uid to the dependency array because uid is apart of the side effect.

"But uid isn't state, it's props!"

That's okay, even though it's not our component's state it's state somewhere else (like in our parent) so it's the same idea.

What about that function, saveToLocalStorage? Does that go in the dependency array since our effect uses it?

In this case no. Before we discuss why, let's compare this to a refactor where saveToLocalStorage is a prop instead:

function ComposeMessage({ uid, defaultMessage, saveToLocalStorage }) {
  const [message, setMessage] = useState(defaultMessage || '')
  
  useEffect(() => {
    saveToLocalStorage(uid, message)
  }, [uid, message, saveToLocalStorage]) // Now it goes here
  return (
    <input
      type="text"
      value={message}
      onChange={e => setMessage(e.target.value)}
    />
  )
}

Now the linter does ask us to put saveToLocalStorage in the dependency array. What's the difference?

Ultimately, React needs to re-run effects if state within those effects changes. Before when saveToLocalStorage was imported, the linter knows it's impossible for that function to "close over" component state that when changed would need to re-run the effect. However, when saveToLocalStorage is a prop the linter doesn't have enough knowledge about how the parent component will implement ComposeMessage to know what that function is. In other words, the linter doesn't explore your whole app to see everywhere ComposeMessage is used and how parents are passing down their props. And even if it did, it wouldn't know how you intent to use it in the future. Because of this uncertainty, the linter now asks that you put saveToLocalStorage in the dependency array.

Here's one example of how the parent component might be implemented:

import { saveToLocalStorage, getFromLocalStorage } from './saveToLocalStorage'

function UserProfile({ uid }) {
  return (
    <ComposeMessage
      uid={uid}
      defaultMessage={getFromLocalStorage(uid)}
      saveToLocalStorage={saveToLocalStorage}
    />
  )
}

Even though saveToLocalStorage is still just a reference to an import, the child ComposeMessage is saying the prop needs to be added to the dependency array. Again, the big difference now though is certainty. Before React knew that saveToLocalStorage didn't close over any state. What if the parent was refactored?

What if we wanted to retain application details in the parent and ComposeMessage just reports when something needs to be saved but doesn't need to know about things like uid? . In that case our code might look like this:

// UserProfile.js
import ComposeMessage from './ComposeMessage'
import { saveToLocalStorage, getFromLocalStorage } from './saveToLocalStorage'

function UserProfile({ uid }) {
  return (
    <ComposeMessage
      defaultMessage={getFromLocalStorage(uid)}
      saveToLocalStorage={message => saveToLocalStorage(uid, message)}
    />
  )
}

// ComposeMessage.js
function ComposeMessage({ defaultMessage, saveToLocalStorage }) {
  const [message, setMessage] = useState(defaultMessage || '')
  
  useEffect(() => {
    saveToLocalStorage(message)
  }, [message, saveToLocalStorage])

  return (
    <input
      type="text"
      value={message}
      onChange={e => setMessage(e.target.value)}
    />
  )
}

Notice that the actual function being passed down as the saveToLocalStorage is an arrow function that wraps the imported saveToLocalStorage. With this refactor, the function passed down does close over uid, it would be important for the saveToLocalStorage prop in ComposeMessage to be included in the dependency array because now it might change. And since React didn't know if it would be needed or not, they had is include it always just in case.

Something else to consider:

If the parent component re-renders for any reason (like state changes or new props of its own), then the arrow function will be re-created each time there's a re-render. This means the function identity for the prop saveToLocalStorage is changing on every one of the parent's re-render. In ComposeMessage, we need to have saveToLocalStorage be in the dependency array to save us from bugs, but now that its identity changes every time the parent re-renders, it's causing us to save to local storage unnecessarily. See a demo of this.

This might be okay to save to local storage a little too often, but what if our side effect was a network request. We might want to avoid it. So what we really need is to keep function's identity stay the same unless we want it to change.

Enter useCallback

To keep the function's identity in sync with the uid, we would implement the parent functions like this:

import ComposeMessage from './ComposeMessage'
import { saveToLocalStorage, getFromLocalStorage } from './saveToLocalStorage'

function UserProfile({ uid }) {
  const save = useCallback(message => {    saveToLocalStorage(uid, message)
  }, [uid])

  return (
    <ComposeMessage
      defaultMessage={getFromLocalStorage(uid)}
      saveToLocalStorage={save}    />
  )
}

The useCallback hook creates a memoized version of a function for this very purpose. Notice that this is another hook that has a dependency array concept. In this case it means the save function will retain the same identity no matter how many times UserProfile is re-rendered. The only exception is if something in its dependency array changes, then a new identity is created.

Let's go though a scenario to help explain:

  1. The parent UserProfile is giving a function to ComposeMessage as a prop
  2. In ComposeMessage only two things will cause the effect to rerun:
  • If the message changes (which is what we want)
  • Or if the saveToLocalStorage prop changes (hold that thought)
  1. The parent function could experience re-renders that would cause ComposeMessage to re-render. When it does, the dependency array of the useEffect will be re-evaluated. In cases where the parent re-renders but doesn't change the uid, we want to ensure that the saveToLocalStorage function doesn't change so that way the effect doesn't run. useCallback does that for us.
  2. If the uid in the parent were to change, the useCallback would create a new identity for save and therefore the subsequent effect gets rerun when the uid changes.

Summary

So when does a function need to go in the dependency array? Whenever it could potentially close over state.

This example probably sums it up perfectly:

const MyComponent = () => {
  // This function doesn't close over state at this moment
  function logData() {
    console.log('logData')
  }

  useEffect(() => {
    logData()
  }, []) // `logData` not required in the dependency array
  // ...
}

Then we console.log some props:

const MyComponent = ({ data }) => {
  // This function DOES close over state now (remember, props
  // are someone else's state)
  function logData() {
    console.log(data)
  }

  useEffect(() => {
    logData()
  }, [logData]) // Now we add it here
  // ...
}

Now that logData is in the dependency array, the new concern is that this function will change with every re-render of MyComponent. So we need to use useCallback:

const MyComponent = ({ data }) => {
  const logData = useCallback(() => {
    console.log(data)
  }, [data])

  useEffect(() => {
    logData()
  }, [logData]) // Now we add it here

  // ...
}

Or, we can do this:

const MyComponent = ({ data }) => {
  useEffect(() => {
    function logData() {
      console.log(data)
    }
    logData()
  }, [data]) // Now, just `data` is needed here

  // ...
}

logData does close over state, but it's apart of the effect itself so we don't need anything but data in the array.

Loading...

React Router

Michael Jackson and Ryan Florence create the React libraries that you use in your apps like React Router and Reach UI. All of our trainers are experts in React and JavaScript so let us share our knowledge with you and your team!

I Love React
© React Training 2019