useEffect
is one of the most widely used hooks in React. Optimizing useEffect
in React gives you a significant boost in performance and sometimes gets your code rid of nasty bugs. I've encountered many situations where there were performance issues or some difficult-to-find bugs due to incorrect usage of useEffect
. Here I'll discuss a few of the optimization patterns which are very easy to adhere to yet very effective.
We often pass JavaScript objects in the useEffect
dependency array. Although nothing is wrong with doing that, sometimes it makes more sense to destructure the JavaScript object and only pass the specific value to the dependency array of useEffect
. Let's take a look at an example. Suppose we have a UserInfo
component which takes the user
object as a prop and fetches the user's transaction data whenever the user changes. We need to only get the user's transaction data when the user changes. Our user object looks like this:
{
"id": 123,
"name": "John Doe",
"imageURL": "...",
"isActive": false
}
The typical implementation will look like this:
function UserInfo({ user }) {
useEffect(() => {
getUserData(user.id);
}, [user]);
return (
...
);
}
This will get user data from the server whenever the user object changes. But looking at the user object, it only makes sense to get the new data when the user.id
changes. There could be instances where the user's data changes but the id
don't, which won't benefit us in making an API call and getting the data from the server again. Let's say the user's isActive
was false
initially and later changed to true
. In this case, we don't need to get the user's transaction data again because the user is still the same. We can avoid this redundant API call by changing our useEffect
dependencies. Here's the refactored component using primitive data types:
function UserInfo({ user }) {
const { id } = user;
useEffect(() => {
getUserData(id);
}, [id]);
return (
...
);
}
With the above optimization of useEffect
, we will only get the user's transactional data when the user id
changes. Remember, in useEffect
dependencies, non-primitives (like objects and arrays) are checked for equality only by reference, not by the value. So the following code will always return false
:
{ name: "Huzaima" } === { name: "Huzaima" }
Sometimes, we use useEffect
to derive a new state from our current state. Suppose we've a counter which totals the length of the string every time the input value is updated. We can implement that like this:
function CharacterCounter() {
const [inputValue, setInputValue] = useState("");
const [compoundStringLength, setCompoundStringLength] = useState(0);
useEffect(() => {
if (inputValue) {
setCompoundStringLength(compoundStringLength + inputValue.length);
}
}, [inputValue, compoundStringLength]);
return (
...
);
}
The above code results in useEffect
being executed infinitely. Why? Because of the incorrect dependency. We're updating compoundStringLength
inside the useEffect
and using the same as a dependency. How do we solve this problem? We need to use compoundStringLength
inside the useEffect
for computation, so we can't get rid of it. But what we can do is remove it from the dependency array. You might think this will result in an incorrect value of compoundStringLength
due to useEffect
being a closure. You're right... and wrong. This is correct because useEffect
is a closure, so we won't get the correct value of compoundStringLength
if we don't use it in the dependency array. Still, there's a way of getting the correct value of compoundStringLength
without specifying it in the dependency array. The state setter function (setCompoundStringLength
) doesn't only accept value but also a function. The signature of that function looks like this:
setCompoundStringLength: (currentStateValue) => newStateValue;
We can leverage the above function signature in the useEffect
to get the correct value of compoundStringLength
for our computations without specifying it as a dependency. The refactored optimization of useEffect
looks like this:
function CharacterCounter() {
const [inputValue, setInputValue] = useState("");
const [compoundStringLength, setCompoundStringLength] = useState(0);
useEffect(() => {
if (inputValue) {
setCompoundStringLength(
(compoundStringLength) => compoundStringLength + inputValue.length
);
}
}, [inputValue]);
return (
...
);
}
Any unmemoized function that you define inside the component is recreated on every render. A very naive example is this:
function EmailValidate() {
const [email, setEmail] = useState("");
const isEmailValid = (input) => {
const regex =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if (regex.test(input)) return "Email is valid";
return "Email is invalid";
};
return (
<>
<input value={email} onChange={(event) => setEmail(event.target.value)} />
<p>{isEmailValid(email)}</p>
</>
);
}
Here you can see that the isEmailValid
function is a pure function and doesn't depend on the component's props and/or state. We can easily optimize this by extracting the function outside the component. Rewriting the above component will look like this:
const isEmailValid = (input) => {
const regex =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if (regex.test(input)) return "Email is valid";
return "Email is invalid";
};
function EmailValidate() {
const [email, setEmail] = useState("");
return (
<>
<input value={email} onChange={(event) => setEmail(event.target.value)} />
<p>{isEmailValid(email)}</p>
</>
);
}
The primary purpose of useEffect
is to handle side effects. Sometimes you have to clean up those side effects like event handler subscriptions or observables. You can return the function having clean-up logic from the function inside useEffect
. Let us take an example of an input field which should clear when the user presses the Escape
key. We can do so like this:
function Input() {
const [input, setInput] = useState("");
const escFunction = useCallback((event) => {
if (event.key === "Escape") {
setInput("");
}
}, []);
useEffect(() => {
document.addEventListener("keydown", escFunction, false);
return () => {
document.removeEventListener("keydown", escFunction, false);
};
}, [escFunction]);
return (
<>
<input value={input} onChange={(event) => setInput(event.target.value)} />
</>
);
}
In the above code, we're returning a function from inside the useEffect
. That function unsubscribes the keydown
event listener, saving us from nasty memory leak problems.
Ever heard of divide and conquer? Yup! That's precisely what we need to do here. Aka separation of concerns, a widely practiced principle in software engineering. React team advocates for separation of concerns in useEffect
, i.e. using different useEffect
for every use case. Suppose we've a component which gets userId
and organizationId
in props. We make an API call to get user and organization data based on the props. We can write useEffect
like this:
useEffect(() => {
getUser(userId);
getOrganization(organizationId);
}, [userId, organizationId]);
The above useEffect
will work fine. But there's one fundamental problem. If the userId
change but the organizationId
don't, getOrganization
will still get called. Why? Because we've added two separate concerns in a single useEffect
. We need to segregate them into two different useEffect
. We can rewrite it like this:
useEffect(() => {
getUser(userId);
}, [userId]);
useEffect(() => {
getOrganization(organizationId);
}, [organizationId]);
Custom hooks are a great way of extracting side effects, which can be reused by many other components. It's also an elegant way of organizing your code. Suppose we've a custom hook to return details about the input string. We can write that custom hook like this:
function useStringDescription(inputValue) {
if (!inputValue) {
return defaultValue;
} else {
const wordCount = inputValue.trim().split(/\s+/).length;
const noOfVowels = inputValue.match(/[aeiou]/gi)?.length || 0;
return {
wordCount,
noOfVowels,
};
}
}
The useStringDescription
hook returns the number of vowels and the word count of a string passed in the argument. We can use this hook like this:
function App() {
const [inputValue, setInputValue] = useState("");
const stringDescription = useStringDescription(inputValue);
const { wordCount, noOfVowels } = stringDescription;
useEffect(() => {
console.log("stringDescription changed", stringDescription);
}, [stringDescription]);
return (
...
);
}
Let's try to use the above setup and see how many times the useEffect
is executed.
You can see that useEffect
is called even when the stringDescription
object's value doesn't change. We can destructure the stringDescription
object as defined in the 1st rule of this article, or we can optimize our custom hook like this:
function useStringDescription(inputValue) {
const [stringDescription, setStringDescription] = useState(defaultValue);
useEffect(() => {
if (!inputValue) {
setStringDescription(defaultValue);
} else {
const wordCount = inputValue.trim().split(/\s+/).length;
const noOfVowels = inputValue.match(/[aeiou]/gi)?.length || 0;
if (
wordCount !== stringDescription.wordCount ||
noOfVowels !== stringDescription.noOfVowels
) {
setStringDescription({
wordCount,
noOfVowels,
});
}
}
}, [inputValue, stringDescription.noOfVowels, stringDescription.wordCount]);
return stringDescription;
}
Now, we're updating the state when any value changes. Let's check out the results:
See? useEffect
didn't get called needlessly because the referential equality holds.
These were the bunch of useEffect
optimizations we can use to make the usage of hooks more effective and get rid of nasty and notoriously hard to find bugs caused due to incorrect usage of hooks. Optimizing useEffect
in React also gives you a boost in performance. What are your secret useEffect
recipes? Let me know on Twitter. And don't forget to read my previous blog about promise speed up.
Also published here.