Use CSS Variables instead of React Context
How and why you should use CSS variables (custom properties) for theming instead of React context.
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 becausemutating 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
!
exhaustive-deps
ruleSo 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.logfunction 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.
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.
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:
useState
/useReducer
so an update will trigger a render.Hope that cleared up some confusion for you! Good luck.
Delivered straight to your inbox.
How and why you should use CSS variables (custom properties) for theming instead of React context.
React 19 introduces terrific primitives for building great forms. Let's dive deep on forms for the web with React.
React Server Components are going to improve the way we build web applications in a huge way... Once we nail the abstractions...
It wasn't a library. It was the way I was thinking about and defining state.