React useEffect Hook Flow

It is important to understand the core concept of Hooks in React Components. This will increase our confidence with usage of hooks and help us understand what is actually happening inside our React components.

This post is to increase your understanding of flow of hooks in a react component with exclusive focus on the most confusing useEffect hook.

As always, let's start with Just Javascript

Take a look at the function below, which returns a string

function App(){
  return 'Hello World';
}

const text = App();
console.log(text); // logs 'Hello World'

We are storing the value returned from App function in variable text and displaying it in the console. We know that Javascript is single threaded and can execute only one line at a time. The flow of execution is top-to-bottom.

When we execute the code, this is what would happen

  1. The Javascript engine first sees a function declaration from line 1 to 3
  2. Then goes to line number 5 where it sees a function being called.
  3. Then JS engine calls that function and assigns the value returned from that function into the text variable.
  4. In the next line the text is displayed in the console.

Now that we understand the flow of Javascript in general, let's explore the useEffect() hook in a react component and explore when it is called and in what order.

React useEffect

Let's explore useEffect in React on three Lifecycle phases of react component.

  1. Mount
  2. Update
  3. Unmount

useEffect on Mount

Take a look at the react component below

function App(){
  React.useEffect(() => {
    console.log('useEffect Ran!')
  }, []);

  return(
    <div>Hello, World!</div>
  )
}

When you scan through this code and find the useEffect with empty [] dependencies, you would have guessed that this hook runs only on mount (exactly like componentDidMount). Yes, you are right, it runs just on the mount. so you would get this in console

useEffect Ran!

Lets see an example with a dependency in useEffect,

function App() {
  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    console.log("Count Changed");
  }, [count]);

  return (
    <button
      onClick={() => {
        setCount((c) => c + 1);
      }}
    >
      {count}
    </button>
  );
}

This is the classic counter example, when we scan the react component and find the useEffect with [count] dependency we would think this would run when the count changes.

So, on the first render the count is 0 and not changed, when you click the button, the count would change, thus calling the useEffect hook right? lets check it out!

This is what would be logged on the first mount of the component.

Count Changed

Whaaaat? We didn't even click the button but the useEffect ran! Why?

Hooks are side-effects, and would be used mostly for performing any side-effects in the component, and the common side effect would be data fetching.

When compared to class Lifecycle methods, mentioning any dependency in a hook would make that hook similar to componentDidUpdate. If you have componentDidUpdate it would still be called on the mount phase!

This is how the hooks are designed to work. No matter how many dependencies you specify and how many hooks you create, every hook would be called on the mount phase of the component.

If you are curious to know why the hooks are designed in this way, take a look at Fun with React Hooks in which Ryan Florence live codes useEffect hook and explains why hooks should only be called in the top level of react component

After the mount phase is completed, our useEffect in the above counter example would be called whenever the count changes.

React.useEffect(() => {
  console.log("Count Changed");
}, [count]);

So, the takeaway from this section is

Every hook in a component would be called on the mount phase (with or without the dependencies specified).

useEffect on Unmount

Now let's look at another example below with the Unmount behaviour.

function Child() {
  React.useEffect(() => {
    console.log("Child useEffect Ran!");

    return () => {
      console.log("cleanUp of Child useEffect Ran!");
    };
  }, []);

  return <div>Hello, From Child!</div>;
}

export default function App() {
  const [showChild, setShowChild] = React.useState(false);

  React.useEffect(() => {
    console.log("useEffect Ran!");

    return () => {
      console.log("cleanUp of useEffect Ran!");
    };
  }, []);

  return (
    <div>
      <div>Hello, World!</div>
      {showChild ? <Child /> : null}
      <button
        onClick={() => {
          setShowChild((b) => !b);
        }}
      >
        {showChild ? "Hide" : "Show"} Child
      </button>
    </div>
  );
}

Our Parent App component renders a Child component which has useEffect with a cleanup function. This cleanup would be executed when the child component unmounts. So, When you render the component and toggle on the Hide/Show child button, You would get the corresponding logs as expected.

If you have 3 useEffects in same component and all does return a cleanup function, then, when the component is unmounted, all the cleanup functions would be called.

Lets see that in action below

function Child() {
  React.useEffect(() => {
    console.log("No Dependency!");

    return () => {
      console.log("cleanUp of No Dependency Ran!");
    };
  });

  React.useEffect(() => {
    console.log("Empty Dependency!");

    return () => {
      console.log("cleanUp of Empty Dependency Ran!");
    };
  }, []);

  return <div>Hello, From Child!</div>;
}

and the output is

The takeaway is

When the component is unmounted, cleanup function from all the useEffects would be executed.

In comparison to class components, where we only have one componentWillUnmount this is the only part that would be executed on the unmount phase of that component.

useEffect on Update

Here comes the interesting part, when you have specified a dependency and if the effect re-runs because of any change in the specified dependencies, it would execute the cleanup functions before executing the hook.

Let's see this behaviour with an example. Open up the console section, and play around with the buttons.

You can play around with the useEffect flow sandbox to see when each effect is getting called and its order.

On the first mount, we see both the useEffects of App running, and when you click on the Increment count button, before running the no deps hook, the cleanup function is executed.

▶️ App Render Start 
🛑 App Render End 
 App: useEffect no deps Cleanup 🧹
🌀 App: useEffect no deps

Similarly, when you click on Show Child button, before running the no deps hook of App, the cleanup is executed.

▶️ App Render Start 
🛑 App Render End 
       ▶️ Child Render Start 
       🛑 Child Render End 
 App: useEffect no deps Cleanup 🧹
       🌀 CHILD: useEffect empty [] 
       🌀 CHILD: useEffect no deps 
🌀 App: useEffect no deps

As seen above, from React v17, the cleanup of parent's effects are executed even before executing child component's useEffect(s).

Below GIF is the full rundown from the sandbox. We can see the cleanup functions are executed before the execution of hook on the update/re-render phase. I have highlighted the cleanups with bigger fonts to notice it easily.

The key takeaway is,

React, when re-running an useEffect, it executes the clean up function before executing the hook.

The full picture of the flow of hooks can be understood from this flow-chart by donavon

I hope this post helps you with understanding of flow of useEffect hook.

To Summarise

  1. Every hook in a component would be called on the Mount phase (with or without the dependencies specified).
  2. When the component is unmounted, cleanup function from all the useEffects are executed.
  3. React, when rerunning an useEffect, it executes the clean up function before executing the hook.

Big thanks to:

15