18
The exhaustive-deps rule has to be treated seriously
(caption: Mrs. Tedesco writing a Todo Application in React with hooks)
It happens when we write useEffect
hooks. We intend to just run some code when X changes, but then ESLint tells us to add Y and Z to the dependency list.
useEffect(() => {
setCount(count + 1)
// eslint-disable-next-line
}, [])
Ignoring this rule is very bad. It opens up our code to a class of weird bugs (ex: count
gets a value from the past). But most importantly, it hides bad design in other parts of the component.
I can strongly claim that EVERY useEffect
can be made compliant with the ESLint rule while still mantaining the desired behavior. The solutions might not be straightforward, but it is always better to change other parts of the code than to add the rule. The overal benefit will be more consistent code.
useEffect
is mostly about updating derived state.
- We have C that depends on A and B.
- When either A or B changes, update C.
- This update requires a side effect in order to happen (otherwise you'd just get the value in the same render without the need of an extra state).
function Page({ id, mode }: { id: number; mode: 'read' | 'edit' }) {
const [formData, setFormData] = useState<null|FormData>(null)
const handleError = useErrorHandler()
useEffect(() => {
loadFormContents(id, mode)
.then(setFormData)
.catch(handleError)
}, [id, mode])
if (!formData) return null;
return <TheForm formData={formData} />
}
(*) when I use the "derived state" idiom here, I am mentioning 2 variables which have some dependency between them. This is a general UI term, forget any old React methods that used this name.
Sometimes we may not straight notice the existence of derived state. The dependency array and the ESLint rule are there to help us. In the example above, the form contents depend on id
. What if the page route changes, bringing in a new id
? We need to handle the prop change.
useEffect
can also happen with an empty dependency array, which showcases that it is also needed for async behavior, even when there's no derived state.
The ESLint plugin is not able to define every variable's lifecycle. It does the basic work of checking if the variable is defined inside of the component (it is not a constant) and if it is one of the known React stable variables.
If you know that a variable is stable (it won't change between renders), you can just safely keep it in the dependency array knowing that it will never trigger an effect.
The most notable examples of stable variables are setState
from useState()
and dispatch
from Redux. Dispatchers from other React libs are usually expected to be stable.
When you feed the dependency array with variables you have created, you can double-check if those variables just change their references when their underlying data changes. Check opportunities of making your variables' references more stable with the help of useCallback
and useMemo
. Forgetting to use useCallback
on a function and then feeding it to useEffect
can lead to a disaster.
Even if an object might have changed its reference, one specific property might have stayed the same. So, when possible, it is interesting to depend on specific properties instead of on a whole object.
We can get rid of dependencies by using the callback form from setState
.
const [state, setState] = useState({ id: 2, label: 'Jessica' })
// good
useEffect(() => {
setState(previous => ({ ...previous, name: 'Jenn' }))
}, [])
// bad
useEffect(() => {
setState({ ...state, name: 'Jenn' })
}, [state])
In this particular case, we were able to remove the state
variable from the array (setState
is already recognized as stable by the plugin).
We previously said that useEffect
is made to handle derived state.
Let's say we have an effect which updates A
and B
based on 1
and 2
.
1, 2 <-- A, B
Maybe A
depends on 1
but not on 2
? In this case, we can split a big useEffect
into smaller ones.
1 <-- A
2 <-- B
Effect splitting can also be achieved by identifying intermediary dependencies.
Example before refactoring:
function Component({ userId, event }: { userId: number, event: Event }) {
const [subscriptionIsExpired, setSubscriptionExpired] = useState(false)
useEffect(() => {
const userSettings: { validUntil: string } = await getUserSettings(userId)
const isExpired = event.startDate > userSettings.validUntil
setSubscriptionExpired(isExpired)
}, [userId, event])
return (...)
}
In the code above, the getUserSettings()
request will be called when event
changes. But it actually has nothing to do with the event
. We may refactor that to:
function Component({ userId, event }: { userId: number, event: Event }) {
const [userSettings, setUserSettings] = useState<null|UserSettings>(null)
const [subscriptionIsExpired, setSubscriptionExpired] = useState<null|boolean>(null)
useEffect(() => {
const userSettings: { validUntil: string } = await getUserSettings(userId)
setUserSettings(userSettings)
}, [userId])
useEffect(() => {
if (!userSettings) {
return
}
const isExpired = event.startDate > userSettings.validUntil
setSubscriptionExpired(isExpired)
}, [userSettings, event])
return (...)
}
Now the async request only depends on userId
. The second effect continues to depend on both userId
(through userSettings
) and event
.
from:
userId, event <-async-- isExpired
to:
userId <-async- userSettings
event, userSettings <-- isExpired
This can still be done without the need for the eslint-disable
by copying the dependency to a state or to a ref.
function Component({ id }) {
// gets the value from the first render
const [initialId] = useState(id) // or useState(() => id)
useEffect(() => {
// ...
}, [initialId])
return (...)
}
React Refs behave a bit differently from React states:
A state is tied to a render through lexical scope. Each render can reference a different state object from a different slice of time; This may have impact on future concurrent render modes?
A ref is just a property tied to the component.
ref.current
will always point to the same thing and will always be current, regardless of where you call it;
It is a bit dangerous to talk abour refs without giving possibly wrong advice. Refs are analogous to setting a property in a class component (instead of setting a state), and doing that was considered anti-pattern at the time.
Disclaimers being said, refs are not counted as dependencies for useEffect
, so you could get rid of a dependency by turning it into a ref. I'd pin out the following properties of something that can likely be turned into a ref:
- It is a value that it is not directly used in the rendered content;
- Thus, when you change it, you don't want a re-render;
- It is used as a bridge between multiple events on the same component, for instance: communication between multiple effects, outbound and inbound events;
Refs are also used to read values from previous renders and to write advanced memoing hooks as present in popular hooks collections.
An effect can programatically be triggered by receiving a "signal reference".
This is not advised as you can usually achieve the same by extracting the code you want to run into a function.
const [trigger, forceEffect] = useState({})
useEffect(() => {
// some code here
}, [trigger])
return <button onClick={() => forceEffect({})}>
Force effect
</button>
18