Ghaleb
Posted on September 17, 2023
Many of the best practices and pitfalls of useEffect
have been discussed in depth in several great articles. However, its relationship with the component's state, and how limited that should be, is probably discussed to a lesser degree. More precisely, the exact purpose of useEffect
seems to allure beginners still.
For instance, in a post listing reasons not to use React, a Reddit user made this argument:
State is immutable in react. Meaning you’ll have to juggle your way around useEffect
The way useEffect
is brought into this statement tells me that the person's pain with React is largely self-inflicted.
Therefore, at the risk of being redundant, considering how well the React docs cover useEffect
, this post aims to be a comprehensive guide discussing the hook in detail—covering topics such as its fundamental purpose, how it works, and when to (and not to) use it.
TL;DR
Jump down to the summary 🙂
The Purpose of useEffect
In React, useEffect
is a double-edged sword.
It is a powerful tool in the world of functional components, but it will also nick the wielder who does not understand how functional components work, and when an effect is needed—which is rarely.
Let's start with this:
useEffect
is not React's answer to reactivity.
In other words, its purpose is not to monitor some state elements through the dependency array to alter other state elements.
An effect is typically something you want to do after rendering takes place, and often involves interaction with the outside world. Like an event handler, it operates outside the main render flow. However, unlike an event handler, it isn't explicitly triggered; it simply runs after a component renders (more on this later).
Effects can include a variety of tasks such as fetching data, subscribing to some event (notice: subscribing, not handling), manipulating the DOM directly, setting up timers, or cleaning up after certain actions.
You can't do any of these things in the rendering code directly for two main reasons:
The rendering code is responsible only for computing the values required to render.
You cannot guarantee how many times a component re-renders. Imagine adding an event listener or making an API call every time a component re-renders.
So, what is the primary purpose of useEffect
?
It is the escape hatch we need to perform a side effect that should not be part of the rendering logic and is not related to an event.
That's all it is, and that's all it should be used for. Excluding some exceptional edge cases, any other form of reliance on useEffect
signals bad design.
How useEffect
Works
Now that we've established what useEffect
is meant for, let's delve into the specifics of how it works.
When does an effect run?
First, let's take a quick look at the various phases a component goes through before being displayed:
A render is triggered
The component is rendered and diffed with the DOM
The changes are committed to the DOM
These phases are always the same, and in that order, regardless of whether the component just mounted or was updated.
So where do effects come in?
Effects run at the end of a commit, after the screen updates.
This ensures that the effect always has access to the most up-to-date state and props. It also makes sense, because effects shouldn't typically be involved in the rendering logic.
function EmptyComponent() {
useEffect(() => {
console.log('This line is logged second');
});
console.log('This line is logged first');
return null;
}
With that in mind, let's update the component's phases:
A render is triggered
The component is rendered and diffed with the DOM
The changes are committed to the DOM
Effects run
The Dependency Array
If you only pass the callback argument to useEffect
, then that effect will run after every component render.
However, you can - and in most cases you should - control when an effect runs by passing a second argument to the hook, known as the dependency array. With this array, the effect will run when the component first mounts, and on any subsequent re-render where the elements in the array change.
useEffect(() => {
console.log('Runs after every render');
});
useEffect(() => {
console.log('Runs once after the initial render');
}, []);
useEffect(() => {
console.log('Runs after every render, provided that `count` changes');
}, [count]);
Be careful here, though. It is easy to fall into the trap of assuming the effect runs because the dependency array changed, which is false. Without triggering a render, the effect doesn't magically run.
function Component() {
const counterRef = useRef(0);
// This effect will only run once,
// no matter how many times counterRef.current is incremented
// because updating a ref value does not trigger a render
useEffect(() => {
console.log({ counter: counterRef.current });
}, [counterRef.current]);
return (
<div>
<button onClick={() => counterRef.current++}>
Increment Counter
</button>
</div>
);
}
With that, let's update the component's phases again:
A render is triggered
The component is rendered and diffed with the DOM
The changes are committed to the DOM
Effects run (subject to dependency array change)
The Cleanup Callback
Because useEffect
can run many times, we need a mechanism to "clean up" some code that is outside the component's control.
Consider this example:
function useTimeLogger(pageName) {
const timeRef = useRef(0);
useEffect(() => {
setInterval(() => {
console.log(`Time since loading ${pageName}: ${timeRef.current++}s`);
}, 1000);
}, [pageName]);
}
Let's assume we have this custom hook being called from various pages in our multi-route app. You load the app in Page A
and the logger begins working as expected. What happens when you go from Page A
to Page B
?
If you guessed that you'll start getting mixed logs for both pages even though you left Page A
, you would be correct. Even though the component of Page A
unmounted, we never canceled its interval.
To cancel an effect, we use the cleanup callback
function useTimeLogger(pageName) {
const timeRef = useRef(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(`Time since loading ${pageName}: ${timeRef.current++}s`);
}, 1000);
return () => {
clearInterval(interval)
timeRef.current = 0;
}
}, [pageName]);
}
React will call your cleanup function after committing the rendered changes and before the effect runs next time.
So, now the component's phases become:
A render is triggered
The component is rendered and diffed with the DOM
The changes are committed to the DOM
Previously registered cleanup callbacks run (subject to dependency array change)
Effects run (subject to dependency array change)
Cleanup callbacks are registered
Note that the cleanup function is also subject to the dependency array. Not all the registered cleanup functions run after a render; only those of which the dependency array changed.
Also, just like all effects run when the component first mounts, all cleanup functions run when the component unmounts.
To see this in action, consider this example:
function Component() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("effect", count);
return () => {
console.log("cleanup", count);
};
}, [count]);
console.log("render", count);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
When the component first mounts, the logs show:
render 0
effect 0
When the counter button is clicked:
render 1
cleanup 0
effect 1
If the component is unmounted at that point, we get:
cleanup 1
Effects and their cleanups should always be together
In some rare cases, you might be tempted to have a useEffect
that only returns a cleanup function. Be careful if you do that because one of two things are very likely to have occurred:
You don't have an effect
You are cleaning up an effect that lives in another
useEffect
In the first case, you likely don't need useEffect
and you should reconsider what that cleanup callback is doing.
The second case is generally a bad idea, which in edge cases can lead to unexpected behavior—especially if the dependency arrays are different. Remember that the cleanup callback will run before the next effect. If your cleanup has different dependencies than your effect, you may get into problems.
In all cases, this is bad practice. I can't picture a scenario in which one might need to do this, except when converting class components to functional components. Sometimes when converting class components, we attempt to mimic the lifecycle hooks with useEffect
so componentWillUnmount
maps to a useEffect
with an empty array and a cleanup callback only.
I once worked on a project that had such code, and it ended up being a bad call in every single instance, which brings us to our next point.
What useEffect
is NOT meant for
1. It is NOT a lifecycle hook
Functional components are fundamentally different from class components. A functional component is designed to fully run on every render. In contrast, in class components, the instance persists until the component unmounts, and only certain methods are called more than once depending on the component's current lifecycle stage.
Accordingly, mapping class components to functional components should be done on a fundamental level as well. We should not be looking at the class's lifecycle methods and thinking about which version of useEffect
to use for mapping it. Nor should we necessarily map all the class's state object directly for that matter. Instead, the component as a whole should be rethought within the bounds of the functional components paradigm.
Always remember: Converting components is rarely a one-to-one mapping operation.
2. It is NOT part of the rendering logic
This is an intriguingly common misuse of the hook. If you have read React code, or gone through some tutorials, you have probably seen something like this before:
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isFormValid, setIsFormValid] = useState(false);
// Update `isFormValid` based on `email` and `password`
useEffect(() => {
const isEmailValid = email.includes('@');
const isPassValid = password.length > 8;
setIsFormValid(isEmailValid && isPassValid);
}, [email, password])
return (
<form>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
disabled={!isFormValid}
onClick={() => login(email, password)}
>
Login
</button>
</form>
)
}
This works, but it's bad practice for three main reasons:
1. Readability-wise, this doesn't scale
We are detaching the event-triggered effect from the event handler. We have no way to go directly from the event handler to the effect, without reading the entire code. This becomes a much bigger problem with more complex components.
This also leads to inconsistent patterns: sometimes you apply a visual change from the event handler, and sometimes the change comes from some useEffect
callback.
2. Performance-wise, this is bad
Recall that an effect runs after the render is committed. Let's walk this through:
Our event handlers trigger a component re-render every time we type
The changes are diffed and committed to the DOM
The effect runs, and it updates the state
The updated state triggers another re-render
So this code commits to the DOM twice after a single trigger. You can imagine how 'twice per trigger' could start being an issue if, say, you had a large table with editable fields.
3. We are managing more state than necessary
As a general rule, the more state the component manages, the more complex it becomes. Accepting this pattern above leads to the bad habit of adding unnecessary state.
This case does not need useEffect
because it is not running an effect in the first place. Once we start dedicating useEffect
to its intended purpose, we start thinking in a different pattern, and we end up with code that is more predictable and more performant.
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// isFormValid becomes a derived value, not a state
const isEmailValid = email.includes('@');
const isPassValid = password.length > 8;
const isFormValid = isEmailValid && isPassValid;
return (
<form>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
disabled={!isFormValid}
onClick={() => login(email, password)}
>
Login
</button>
</form>
)
}
Now, if the computation of a derived value is expensive, the solution wouldn't be the dependency array of usEffect
. The solution is useMemo
!
const isFormValid = useMemo(() => {
return someComplexValidator(email, password);
}, [email, password, someComplexValidator])
3. It is NOT an event listener
Assume the code above wasn't validating the form, but was instead saving the form data to localStorage
.
⚠️WARNING: This example is only to illustrate a misuse of
useEffect
. Please don't store passwords inlocalStorage
.
You might argue that this is an effect because it is not part of the rendering logic, and decide to use useEffect
for it.
useEffect(() => {
localStorage.setItem("email", email);
localStorage.setItem("password", password);
}, [email, password])
The logic above is indeed a side effect. However, this effect is triggered by the onChange
events of the email and password input fields.
While relying on useEffect
here does not entail adding state or rendering twice, it does detach the event-triggered-effect from the event handler. Despite resulting in a few more lines of code, it is far cleaner to keep all event-handling logic in the event handler.
Summary
The useEffect
hook is a powerful tool in a React developer's toolkit, but it is not a Swiss army knife designed for a multitude of tasks. Nor is it a tool to group event-triggered effects of multiple elements in one place.
It is mainly an escape hatch for side effects that are not triggered by events. Your component will be much more predictable and maintainable if you understand its core purpose and stick to it.
As a rule of thumb, only use useEffect
when two conditions apply:
The logic is a genuine side effect
The logic is not triggered by an event
If your useEffect
callback does not involve a side effect and involves render-related logic, then you likely need a computed value. If the computation is expensive and you only want it reacting to a specific state change, use useMemo
.
The phases a component goes through are:
A render is triggered
The component is rendered and diffed with the DOM
The changes are committed to the DOM
Previously registered cleanup callbacks run (subject to dependency array change)
Effects run (subject to dependency array change)
Cleanup callbacks are registered
Just because the hook uses a dependency array, doesn't mean it is listening for changes. An effect runs after a render is triggered, not because a dependency value changed.
Finally, do not treat useEffect
as a lifecycle hook. Convert class components by rethinking them in the functional paradigm instead of just mapping their state and lifecycle methods.
Posted on September 17, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.