Articles

Why you shouldn't put refs in a dependency array

Kent C. Dodds
Kent C. Dodds

Let's say we've got a form that allows you to specify a username. When you try to submit an invalid value, it will show an error message and refocus on the input so you can fix your mistake.


import * as React from 'react'
function UsernameForm({
initialUsername = '',
onSubmitUsername,
}: {
initialUsername?: string
onSubmitUsername: (username: string) => void
}) {
const [username, setUsername] = React.useState(initialUsername)
const [touched, setTouched] = React.useState(false)
const usernameInputRef = React.useRef<HTMLInputElement>(null)
const usernameIsLowerCase = username === username.toLowerCase()
const usernameIsLongEnough = username.length >= 3
const usernameIsShortEnough = username.length <= 10
const formIsValid =
usernameIsShortEnough && usernameIsLongEnough && usernameIsLowerCase
const displayErrorMessage = touched && !formIsValid
React.useEffect(() => {
if (displayErrorMessage) usernameInputRef.current?.focus()
}, [displayErrorMessage])
let errorMessage = null
if (!usernameIsLowerCase) {
errorMessage = 'Username must be lower case'
} else if (!usernameIsLongEnough) {
errorMessage = 'Username must be at least 3 characters long'
} else if (!usernameIsShortEnough) {
errorMessage = 'Username must be no longer than 10 characters'
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setTouched(true)
if (!formIsValid) return
onSubmitUsername(username)
}
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setUsername(event.currentTarget.value)
}
function handleBlur() {
setTouched(true)
}
return (
<form name="usernameForm" onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="usernameInput">Username:</label>
<input
ref={usernameInputRef}
id="usernameInput"
type="text"
value={username}
onChange={handleChange}
onBlur={handleBlur}
pattern="[a-z]{3,10}"
required
aria-describedby={displayErrorMessage ? 'error-message' : undefined}
/>
</div>
{displayErrorMessage ? (
<div role="alert" className="error-message">
{errorMessage}
</div>
) : null}
<button type="submit">Submit</button>
</form>
)
}

There's a bit going on there, but let's zoom in on the useEffect. That's responsible for focusing the input when an error is displayed so the user can fix the problem.


React.useEffect(() => {
if (displayErrorMessage) usernameInputRef.current?.focus()
}, [displayErrorMessage])

If you know me, you know that I'm a firm proponent of the exhaustive-deps rule from the eslint-plugin-react-hooks package. So you might wonder why my dependency array doesn't include: usernameInputRef.current. Isn't that one of the dependencies of my effect callback? After all, what would happen if that value were to change?

Well, let's try adding it to the array:


React.useEffect(() => {
if (displayErrorMessage) usernameInputRef.current?.focus()
}, [displayErrorMessage, usernameInputRef.current])

Ah, we get a lint warning from the exhuastive-deps rule:


React Hook React.useEffect has an unnecessary dependency: 'usernameInputRef.current'.
Either exclude it or remove the dependency array.
Mutable values like 'usernameInputRef.current' aren't valid dependencies because
mutating them doesn't re-render the component.
eslint(react-hooks/exhaustive-deps)

Alright, let's dig into that warning. Remember:

useRef is similar to useState except changing the value doesn't trigger a re-render.

In our example above, we're using useRef to keep track of a DOM node, but you can use it to keep track of any value whatsoever, just like useState (bet you didn't think about putting a function in useState before did you πŸ˜‰, you totally can though).

The fact that an update the a ref.current value doesn't trigger a re-render is an intentional feature. React doesn't keep track of the current value of a ref. You're responsible for referencing and mutating that value yourself. Because referencing DOM nodes is such a common use case, React will set the current value for you when you pass a ref prop to an element. But other than that, all React promises is that it will store your object and associate it to a particular instance of a component for as long as that component exists.

By the way, that's what differentiates a ref from just a regular variable outside the component. useRef ensures that the value is associated with a particular instance of a component.

Alright, so let's bring this back to the warning. Let's recall the purpose of a dependency array: It's there so React can do something when there are changes in the values provided each time the component renders. And that's the answer right there! React can't know that the value changed if a change doesn't trigger a render! Here's a quick contrived example:


function Counter() {
const countRef = React.useRef(0)
React.useEffect(() => {
console.log(countRef.current)
}, [countRef.current])
const increment = () => (countRef.current += 1)
return <button onClick={increment}>Click me</button>
}

I can click that button over and over again, but I'm never going to get that useEffect callback to run again because there's no re-render associated to the update in the value, I won't get any updated logs!

"But Kent" you ask, "what if I want an update to a ref to result in a re-render?" If that's the case, what you actually want is useState/useReducer, not useRef!

The rule that governs exceptions to the exhaustive-deps rule

So refs are an exception to the exhaustive-deps rule, but they're actually not the only exception. The exception itself is general. Here's the general rule for the exception:

Anything you use in your effect callback that won't trigger a re-render when updated should not go into the dependency array.

Additionally (and consequentially), you should not expect any change in such values to result in the effect callback getting called. If you need the callback to be called when those things change, then you need to put it in useState (or useReducer).

This general rule is why if you pass the value of a module-level variable into a dependency array like this you'll get a similar lint warning:


let log = console.log
function Comp() {
React.useEffect(() => {
log(new Date())
}, [log]) // <-- 🚨 eslint warning here
return <div>{/* stuff here */}</div>
}

Here's what the linter will tell you about that:


React Hook React.useEffect has an unnecessary dependency: 'log'.
Either exclude it or remove the dependency array. Outer scope values like 'log'
aren't valid dependencies because mutating them doesn't re-render the component.
eslint(react-hooks/exhaustive-deps)

This is because even if I did reassign that log variable to something else at some point, React wouldn't know about it so you'd end up with a stale side-effect anyway. So just don't list it, and if you do want a change to trigger the effect to run, then put it in state!

Same thing happens with imports (with the added benefit of the fact that you can't reassign these values anyway):


import log from './logger'
function Comp() {
React.useEffect(() => {
log(new Date())
}, [log]) // <-- 🚨 eslint warning here
return <div>{/* stuff here */}</div>
}

The lint warning for this is actually identical to the variable form.

Custom hooks and the ref object itself

You won't get a warning with code like this:


function Comp() {
const logRef = React.useRef(console.log)
React.useEffect(() => {
logRef.current(new Date())
}, []) // <-- βœ… No eslint warning here
return <div>{/* stuff here */}</div>
}

That's because you're not using any values in your useEffect that the lint plugin knows can change on re-renders.

However, you also won't get a warning with code like this:


function Comp() {
const logRef = React.useRef(console.log)
React.useEffect(() => {
logRef.current(new Date())
}, [logRef]) // <-- βœ… No eslint warning here
return <div>{/* stuff here */}</div>
}

That's because the logRef value can never change. So it actually makes no difference whether you include it or not and I guess the authors of the lint rule decided to not bother you about something that doesn't matter. Good call.

But here's an interesting case. What about a custom hook that accepts a ref?


function useDateCall(cbRef) {
React.useEffect(() => {
cbRef.current(new Date())
}, []) // <-- 🚨 eslint warning here
}
function Comp() {
const logRef = React.useRef(console.log)
useDateCall(logRef)
return <div>{/* stuff here */}</div>
}

The warning we get there is:


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

The reason we get the warning there when we didn't get it within the component is because ESLint is pretty limited in its ability to trace what you're doing with your JavaScript. So the React plugin for ESLint can't know that cbRef is actually a ref. So just to be safe, it warns you.

Luckily, as we just learned, including it in the dependency array doesn't make any difference anyway, so just include it!


function useDateCall(cbRef) {
React.useEffect(() => {
cbRef.current(new Date())
}, [cbRef]) // <-- βœ… No eslint warning here
}
function Comp() {
const logRef = React.useRef(console.log)
useDateCall(logRef)
return <div>{/* stuff here */}</div>
}

And we're all good.

Conclusion

So the reason you shouldn't list a ref in your useEffect dependency array is because it's an indication that you want the effect callback to re-run when a value is changed, but you're not notifying React when that change happens. So the solution is to either:

  1. Not include it in the array 2. Put that value in useState/useReducer so an update will trigger a render.

Hope that cleared up some confusion for you! Good luck.

Get my free 7-part email course on React!

Delivered straight to your inbox.