loading...
Cover image for React.useEffect hook explained in depth on a simple example

React.useEffect hook explained in depth on a simple example

mpodlasin profile image Mateusz Podlasin ・10 min read

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 useEffects 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 paris.

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 or render function, but should always be wrapped in a callback passed to useEffect.
  • 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 the useEffect. 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)

Posted on by:

mpodlasin profile

Mateusz Podlasin

@mpodlasin

I write in-depth articles about JavaScript, React and functional programming.

Discussion

markdown guide