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?
See Our Public Workshops:
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 (<div><input type="text" onChange={(e) => setMessage(e.target.value)} /></div>)}
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 thingsreturn <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 herereturn <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.jsimport ComposeMessage from './ComposeMessage'import { saveToLocalStorage, getFromLocalStorage } from './saveToLocalStorage'function UserProfile({ uid }) {return (<ComposeMessagedefaultMessage={getFromLocalStorage(uid)}saveToLocalStorage={(message) => saveToLocalStorage(uid, message)}/>)}// ComposeMessage.jsfunction 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:
- The parent
UserProfile
is giving a function toComposeMessage
as a prop - 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)
- The parent function could experience re-renders that would cause
ComposeMessage
to re-render. When it does, the dependency array of theuseEffect
will be re-evaluated. In cases where the parent re-renders but doesn't change theuid
, we want to ensure that thesaveToLocalStorage
function doesn't change so that way the effect doesn't run.useCallback
does that for us. - If the
uid
in the parent were to change, theuseCallback
would create a new identity forsave
and therefore the subsequent effect gets rerun when theuid
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 momentfunction 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.