useEffect
hook is an extremely powerful an versatile tool, allowing you to even create your own, custom hooks.
But using it involves surprising amounts of subtlety, so in this article we will see step by step how exactly this hook works.
In order to not lose focus, we will be using the most basic example imaginable and at each step we will control what is happening, by logging messages to the browser console.
You are highly encouraged to follow along this article and code all the examples yourself, using for example an online React repl like this one.
Let's get started!
Basic use & behavior
useEffect
is - as the name suggests - a hook to perform arbitrary side effects during a life of a component.
It is basically a hook replacement for the "old-school" lifecycle methods componentDidMount
, componentDidUpdate
and componentWillUnmount
.
It allows you to execute lifecycle tasks without a need for a class component. So you can now make side effects inside a functional component. This
was not possible before, because creating side effects directly in a render
method (or a body of a functional component) is strictly prohibited. Mainly because we don't really control (and shouldn't really think about) how many times render
function will be called.
This unpredictability issue is fixed with the use of useEffect
.
So let's create a simple functional component, which we will call Example
:
const Example = () => {
return <div />;
};
It doesn't really do anything interesting, because we want to keep it as simple as possible, for the purposes of the explanation.
Note that we didn't use the shortened arrow syntax, where we can simply provide a returned value of a function (in that case a div
element) in place of the body of the function. That's because we already know we will be adding some side effects in that body.
Let's do just that.
I mentioned previously that it is prohibited to make side effects directly in the body of the component. That's where the useEffect
hook comes in:
import { useEffect } from 'react';
const Example = () => {
useEffect(() => {
console.log('render');
});
return <div />;
};
As you can see, we used useEffect
function, which accepts a callback function as an argument. Inside the callback we just made a simple console.log
, which will help us find out when this effect is executed.
If you render that component and look into a browser console, you will see render
logged there once.
Okay. So we know that the callback is for sure called when the component first gets created and rendered. But is that all?
In order to find out, we need to make a bit more involved example, that will allow us to rerender the Example
component on command:
import { useState } from 'react';
const Wrapper = () => {
const [count, setCount] = useState(0);
const updateCount = () => setCount(count + 1);
return (
<div>
<button onClick={updateCount}>{count}</button>
<Example />
</div>
};
We created a new component called Wrapper
. It renders both our previous component, Example
, and a button. The button displays a counter value, initially set at 0
. After the button is clicked, the counter increases by one.
But the counter itself doesn't really interests us. we just used it as a trick to cause a rerender of the Example
component. Whenever you click on the counter button, state of Wrapper
component gets updated. This causes a rerender of the Wrapper
, which in turn causes a rerender of the Example
component.
So basically you are causing a rerender of the Example
on each click of the button.
Let's now click few times on the button and see what is happening in the console.
It turns out that after each click, the render
string again appears in the console. So if you click at the button 4 times, you will see 5 render
strings in the console: one from initial render and one from the rerenders that you caused by clicking on the button.
Ok, so this means that a callback to useEffect
is called on initial render and every rerender of the component.
Does it get called also when component gets unmounted and disappears from the view? In order to check that, we need to modify the Wrapper
component once more:
const Wrapper = () => {
// everything here stays the same as before
return (
<div>
<button onClick={updateCount}>{count}</button>
{count < 5 && <Example />}
</div>
};
Now we are rendering Example
conditionally, only if count
is smaller than 5. It means that when the counter hits 5, our component will disappear from the view and React mechanism will trigger it's unmounting phase.
It now turns out that if you click on the counter button 5 times, the render
string will not appear in the console the last time. This means it will appear only once on initial render and 4 times on rerenders on the component, but not on the 5th click, when the component disappears from the view.
So we learned that unmounting the component does not trigger the callback.
Then how do you create a code that is an equivalent of the componentWillUnmount
lifecycle method? Let's see.
const Example = () => {
useEffect(() => {
console.log('render');
return () => {
console.log('unmount');
};
});
return <div />;
};
If your head spins from all the callbacks, that's fine - mine does to. But note that we didn't do anything too crazy. The callback passed to the useEffect
function now returns an another function. You can think of that returned function as a cleanup function.
And here awaits us a surprise. We expected this cleanup function to run only on unmount of the component, that is when counter on our button goes from 4 to 5.
Yet that is not what happens. If you run this example in the console, you will see that string unmount
appears in the console at the end when component is unmounted, but also when the component is about to be rerendered.
So in the end, the console looks like that:
render
unmount
render
unmount
render
unmount
render
unmount
render
unmount
You can see that every render
(when the useEffect
main callback gets executed) is accompanied by respective unmount
(when the cleanup function is executed).
Those two "phases" - effect and cleanup - always go in pairs.
So we see that this model differs from traditional lifecycle callbacks of a class components. It seems to be a bit stricter and more opinionated.
But why was it designed this way? In order to find out, we need to learn how useEffect
hook cooperates with component props.
useEffect & props
Our Wrapper
component already has a state - count
- that we can pass into Example
component, to see how its useEffect
will behave with the props.
We modify Wrapper
component in the following way:
<Example count={count} />
And then we update the Example
component itself:
const Example = ({ count }) => {
// no changes here
return <div>{count}</div>;
};
It turns out that simply passing the counter as a prop or even displaying it in div
element of the component does not change the behavior of the hook in any way.
What is more, using this prop in useEffect
behaves as we would expect, while also giving us a bit more insight into how useEffect
s main callback and cleanup functions are related.
This code, where we simply add count
prop to our logs:
const Example = ({ count }) => {
useEffect(() => {
console.log(`render - ${count}`);
return () => {
console.log(`unmount - ${count}`);
};
});
return <div>{count}</div>;
};
will result in the following output, when you start clicking on the counter button:
render - 0
unmount - 0
render - 1
unmount - 1
render - 2
unmount - 2
render - 3
unmount - 3
render - 4
unmount - 4
This might seem like a trivial result, but it enforces what we learned about the main callback of useEffect
and its cleanup function - they always go in pairs.
Note that each cleanup function even utilizes the same props as its respective callback.
For example first callback has count set to 0 and its cleanup function utilizes the same value, instead of 1, which belongs to the next pair of the effect and cleanup.
This is a key to the design of the useEffect
hook. Why is that so important, you might ask?
Imagine for example that your component has to establish a connection to a service with a following API:
class Service {
subscribe(id) {},
unsubscribe(id) {},
}
This service requires you to unsubscribe with exactly the same id
that you used to subscribe to it in the first place. If you don't do that, you will leave an opn connection, which will cause leaks that ultimately might even crash the service!
Luckily useEffect
enforces a proper design with its architecture.
Note that if id
required by the Service
is passed via props to the component, all you have to do is to write inside that component:
useEffect(() => {
service.subscribe(id);
return () => {
service.unsubscribe(id);
};
});
As we have seen with our logging examples, useEffect
will make sure that each subscribe
is always followed by unsubscribe
, with exactly the same id
value passed to it.
This architecture makes writing sound and safe code very straightforward, no matter how often the component updates and no matter how frantically its props are changing.
Controlling the updates
For people who got used to class component lifecycle methods, useEffect
often seems limiting at the beginning.
How do you add an effect only at the very first render?
How do you run a cleanup function only at the end of components life, instead of after every rerender?
In order to find out the answers to those questions, we need to describe one last mechanism that useEffect
offers to us.
As a second argument, useEffect
optionally accepts an array of values. Those values will be then compared to the previous values, when deciding if the effect should be ran or not.
It works a bit like shouldComponentUpdate
for side effects. If the values changed, the effects will be ran. If none of the values changed, nothing will happen.
So we can edit our Example
component like so:
const Example = ({ count }) => {
useEffect(() => {
// everything here stays the same as before
}, [count]);
return <div>{count}</div>;
};
Because our useEffect
function used count
prop and because we want to log a string to the console every time the count changes, we provided a second argument to the useEffect
- an array with only one value, namely the prop which we want to observe for changes.
If between rerenders the value of count
does not change, the effect will not be ran and no log with appear in the console.
In order to see that it's really what happens, we can edit our Wrapper
component:
const Wrapper = () => {
// everything here stays the same as before
return (
<div>
<button onClick={updateCount}>{count}</button>
{count < 5 && <Example count={count} />}
{count < 5 && <Example count={-1} />}
</div>
);
};
You can see that we are now rendering two Example
components. One - as before - gets passed count
value as a prop, while the other gets always the same value of -1.
This will allow us to compare the difference in the console outputs, when we click repeatedly on the counter button. Just remember to include [count]
array as a second parameter to useEffect
.
After clicking on the counter several times, we get:
render - 0
render - -1 // this was logged by the second component
unmount - 0
render - 1
unmount - 1
render - 2
unmount - 2
render - 3
unmount - 3
render - 4
unmount - 4
unmount - -1 // this was logged by the second component
So, as you can see, if you include count
in the array of the second argument to useEffect
, the hook will only be triggered when the value of the prop changes and at the beginning and the end of the life of the component.
So, because our second Example
component had -1 passed as count
the entire time, we only saw two logs from it - when it was first mounted and when it was dismounted (after count < 5
condition began to be false).
Even if we would provide some other props to the Example
component and those props would be changing often, the second component would still log only twice, because it now only watches for changes in count
prop.
If you wanted to react to changes of some other props, you would have to include them in the useEffect
array.
On the other hand, in the first Example
component from the snippet, value of the count
prop was increasing by one on every click on the button, so this component was making logs every time.
Let's now answer a questions that we asked ourselves earlier. How do you make a side effect that runs only at the beginning and at the end of components lifecycle?
It turns out that you can pass even an empty array to the useEffect
function:
useEffect(() => {
console.log('render');
return () => {
console.log('unmount');
};
}, []);
Because useEffect
only triggers callbacks at the mount and unmount, as well as value changes in the array, and there is no values in the array, the effects will be called only at the beginning and the end of components life.
So now in the console you will see render
when the component gets rendered for the first time and unmount
when it disappears. Rerenders will be completely silent.
Summary
That was probably a lot of to digest. So let's make a brief summary, that will help you remember the most important concepts from this article:
-
useEffect
hook is a mechanism for making side effects in functional components. Side effects should not be caused directly in components body orrender
function, but should always be wrapped in a callback passed touseEffect
. - You can optionally return in the callback another callback, which should be used for cleanup purposes. The main callback and cleanup callback are always triggered in pairs, with exactly the same props.
- By default
useEffect
callback (and corresponding cleanup) is ran on initial render and every rerender as well as on dismount. If you want to change that behaviour, add an array of values as a second argument to theuseEffect
. Then the effects will be ran only on mount and unmount of the component or if the values in that array changed. If you want to trigger the effects only on mount and unmount, simply pass an empty array.
So that's it! I hope this article helped you deeply understand how useEffect
works.
It might seem like a basic and easy hook, but now you see just how much complexity and subtlety is behind it.
If you enjoyed this article, considered following me on Twitter, where I will be posting more articles on JavaScript programming.
Thanks for reading!
(Cover Photo by milan degraeve on Unsplash)
Top comments (8)
Great article! Thanks for writing this.
Small typo:
Thanks a lot!
Fixing now. :)
Fix: rendered callback will be cleaned up on the next rendering, either has dependencies or not.
stackoverflow.com/questions/570230...
This was very enlightening. Thanks for writing this down.
Thanks Tom! I am glad I could help!
thanks for this, help a lot =]
Great article!
Thx for the article. This is exactly what I was looking for: useEffect
behind the scenes