Objects as useEffect deps
Some time ago my company decided to switch from native to React Native. For the last 2 years, I have gathered enough experience (I hope) to be able to say a word or two about React.
The first thing I want to cover is a common mistake developers make, even those who come from ReactJS. This mistake is about being not careful with useEffect's deps list.
How useEffect compares dependencies?
When a "dependency" from the deps list changes then useEffect will run. It does a ===
to check the change. The ===
is fine for strings, numbers, and booleans however for objects it only compares references, not actual equality.
import "./styles.css";
import { useEffect, useState } from "react";
type SomeObject = {
id: string
}
export default function App() {
const [counter, setCounter] = useState(0);
const someObject: SomeObject | undefined = { id: "fake-id" };
useEffect(() => {
if (someObject != null) {
setCounter((c) => c + 1);
}
}, [someObject]);
return (
<View style={styles.container}>
<Text style={styles.paragraph}>
Counter: {counter}
</Text>
</View>
);
}
This code will end up with an infinite useEffect increasing the counter. In this example, es-lint actually tells you going to have problems:
When it's safe to use an object with useEffect?
Not always passing an object into useEffect would end up with an infinite loop. useEffect uses ===
so as long as the object has a constant reference then you are safe.
State value from useState
has a constant reference until the setter is called. The same applies to a state from useReducer
or a state stored in Redux
. As long you are reading "state" objects, you should be safe.
useQuery
from Apollo also keeps constant reference (however I would assume the reference could change during a refetch even if the object has not changed in value)
Transformations - be careful
Sometimes, especially when the backend sends data in a weird format you may want to write a custom hook that would transform the response to a more frontend readable format:
const useFetchPatient = (): Patient => {
const { data: response } = useQuery(LOAD_PATIENT_QUERY)
return {
firstName: response.edges[0].node.firstName,
lastName: response.edges[0].node.lastName,
}
}
export default function App() {
const [counter, setCounter] = useState(0);
const patient = useFetchPatient()
useEffect(() => {
setCounter((c) => c + patient.firstName.length);
}, [patient]);
return (
<View style={styles.container}>
<Text style={styles.paragraph}>
Counter: {counter}
</Text>
</View>
);
}
Even that we have used useQuery
which returns a constant reference to its data, we still have an infinite the useEffect
. This is because of the transformation which happens inside useFetchPatient
. With transformations don't forget to wrap the transformed data with useMemo
:
type Patient = {
firstName: string;
lastName: string;
}
const useFetchPatient = (): Patient => {
const { data: response } = useQuery(LOAD_PATIENT_QUERY)
const transformed = useMemo((): Patient => ({
firstName: response.edges[0].node.firstName,
lastName: response.edges[0].node.lastName,
}), [response])
return transformed
}
export default function App() {
const [counter, setCounter] = useState(0);
const patient = useFetchPatient()
useEffect(() => {
setCounter((c) => c + patient.firstName.length);
}, [patient]);
return (
<View style={styles.container}>
<Text style={styles.paragraph}>
Counter: {counter}
</Text>
</View>
);
}
Recap
When you work with useEffect
make sure values which are passed to its dependence have a constant reference. When that requirement is not met, expect your devices to become hot 🔥.
Feel free to play with the examples I have provided.
In the future article, I'm planning to cover how we can deal with callbacks passed to useEffect's deps. Sneak peek: useCallback
is the way, but IMO not the best one :)