DEV Community

Cover image for Understanding the flow of React's useEffect hook
Som Shekhar Mukherjee
Som Shekhar Mukherjee

Posted on • Updated on

Understanding the flow of React's useEffect hook

React's useEffect hook is used quite often in applications. It is used to perform side effects in your components like subscribing/unsubscribing to events, making API requests etc.

In this article we're going to discuss the flow in which things happen when working with this hook.

Order in which "Setup" and "Cleanup" functions are called

The useEffect hook accepts a function as the only argument which is often referred as the "Setup" function and you can optionally return a function from this "Setup" which is often referred as the "Cleanup" function.

In this example we'll see the flow in which these Setup and Cleanup functions are called.

const { useState, useEffect } = React;

const Counter = () => {
    const [count1, setCount1] = useState(0);
    const [count2, setCount2] = useState(0);

    useEffect(() => {
        console.log("useEffect no dependency ran");

        return () => console.log("useEffect no dependency cleanup ran");
    });

    useEffect(() => {
        console.log("useEffect empty dependency ran");

        return () => console.log("useEffect empty dependency cleanup ran");
    }, []);

    useEffect(() => {
        console.log("useEffect count1 as dependency ran");

        return () => console.log("useEffect count1 as dependency cleanup ran");
    }, [count1]);

    useEffect(() => {
        console.log("useEffect count2 as dependency ran");

        return () => console.log("useEffect count2 as dependency cleanup ran");
    }, [count2]);

    return (
        <>
            <button onClick={() => setCount1((c) => c + 1)}>{count1}</button>
            <button onClick={() => setCount2((c) => c + 1)}>{count2}</button>
        </>
    );
};

const App = () => {
    const [showCounter, setShowCounter] = useState(false);

    return (
        <main className="App">
            <label htmlFor="toggleCounter">Toggle Counter: </label>
            <input
                id="toggleCounter"
                type="checkbox"
                checked={showCounter}
                onChange={({ target }) => setShowCounter(target.checked)}
            />
            <div>{showCounter && <Counter />}</div>
        </main>
    );
};

const rootEl = document.getElementById("root");
ReactDOM.render(<App />, rootEl);
Enter fullscreen mode Exit fullscreen mode

Take a moment to understand the example above, it looks lengthy because it has a bunch of useEffect calls but its fairly simple otherwise.

Our focus is on the Counter component and all our logs are from this component.

So, initially there are no logs because the Counter component is not yet mounted (as showCounter state is set to false).


Let's click on the "Toggle Counter" checkbox

This updates the showCounter state and a re-render happens and we have our Counter mounted for the first time.

Logs
useEffect no dependency ran
useEffect empty dependency ran
useEffect count1 as dependency ran
useEffect count2 as dependency ran

💡 Observation: Notice all setups ran and they ran in the order they were called.

🚀 This is because all Setups run on mount irrespective of the dependency array and they run in the exact same order in which we called them. Also, no Cleanups run on mount.

(Clear the logs before moving to the next section)


Let's click on the first counter button

Logs
useEffect no dependency cleanup ran
useEffect count1 as dependency cleanup ran
useEffect no dependency ran
useEffect count1 as dependency ran

💡 Observation: Notice only two effects ran this time and both cleanup and setup ran for these two (and they still run in order they were called).

🚀 Its because on re-renders the Effect hook (both Cleanup and Setup) runs only if the dependencies change (count1 changed) or if the second argument is skipped completely.

💡 Observation: Notice Cleanups run before Setups for both "no dependency" Effect hook and "count1" Effect hook.

🚀 So, when both Cleanup and Setup has to run for a particular effect hook, the Cleanup will run before the Setup.

If you want to explore why useEffect runs after every render and not just on unmount, React docs does a really great job of explaining this.

(Clear the console before moving to the next section)


Let's now click on the "Toggle Counter" checkbox again

This updates the showCounter state and unmounts the Counter component.

Logs

useEffect no dependency cleanup ran
useEffect empty dependency cleanup ran
useEffect count1 as dependency cleanup ran
useEffect count2 as dependency cleanup ran

💡 Observation: Notice all cleanups ran and they ran in the order they were called.

🚀 This is because all Cleanups run on unmount irrespective of the dependency array and they run in order. Also, no Setups run on unmount.


🔥 CheatSheet

Phase Setups Cleaups Condition
Mount All None None
Re-render Some Some Dependency Array
Unmount None All None

Children's useEffect hooks run before Parent's

Consider the example below, it's to explain a small point that Children's useEffect hooks will always run before Parent's useEffect hook.

const { useEffect } = React;

const Child = () => {
    useEffect(() => {
        console.log("Child useEffect ran");
    });
    return <p>Child</p>;
};

const App = () => {
    useEffect(() => {
        console.log("App useEffect ran");
    });
    return <Child />;
};

const rootEl = document.getElementById("root");
ReactDOM.render(<App />, rootEl);
Enter fullscreen mode Exit fullscreen mode

Logs
Child useEffect ran
App useEffect ran


useEffect hooks are called Asynchronously

The example below demonstrates a really important point which is that useEffect hooks are called Asynchronously.

const { useEffect } = React;

const App = () => {
    console.log("Before useEffect");
    useEffect(() => {
        console.log("Inside useEffect");
    });
    console.log("After useEffect");
    return <h1>Hello World</h1>;
};

const rootEl = document.getElementById("root");
ReactDOM.render(<App />, rootEl);
Enter fullscreen mode Exit fullscreen mode

Logs
Before useEffect
After useEffect
Inside useEffect

💡 Observation: Notice the "Inside useEffect" log is printed after the "After useEffect" log.

🚀 And this is because React calls useEffect asynchronously after React has finished rendering.

In other words useEffect doesn't run the moment you call it, it runs after React has completed rendering.

I will mention this point again twice in the coming section because I feel this is really important to understand.


Making API calls inside the useEffect hook

Quite often we make async requests to external APIs inside the useEffect hook. So, in this section we would observe the flow of our code in such a scenario.

const UserInfo = ({ userId }) => {
  const [user, setUser] = React.useState(null);
  const [error, setError] = React.useState(null);

  console.log("%cBefore useEffect", "color: yellow");

  React.useEffect(() => {
    console.log("%cInside useEffect", "color: cyan");

    setError(null);

    (async function fetchUser() {
      if (!userId) return;

      try {
        const res = await fetch(
          `https://jsonplaceholder.typicode.com/users/${userId}`
        );
        const data = await res.json();

        if (!Object.entries(data).length) throw new Error("No data found");

        setUser(data);
      } catch (e) {
        setError("Something went wrong");
      }
    })();
  }, [userId]);

  console.log("%cAfter useEffect", "color: coral");

  if (error) return <p>{error}</p>;

  if (!user) return <p>Loading...</p>;

  if (user) return <pre>{JSON.stringify(user, null, 2)}</pre>;
};

const UserSearchForm = ({ setUserId }) => {
  const handleSubmit = (e) => {
    e.preventDefault();
    setUserId(e.target.elements.userId.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="userId">User Id:</label>
      <input type="text" id="userId" placeholder="Enter User Id" />
      <button type="submit">Search</button>
    </form>
  );
};

const App = () => {
  const [userId, setUserId] = React.useState("");
  return (
    <main>
      <h1>Find User Info</h1>
      <UserSearchForm setUserId={setUserId} />
      {userId && <UserInfo userId={userId} />}
    </main>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));
Enter fullscreen mode Exit fullscreen mode

Consider the above example, our focus is on the UserInfo component that makes async request to an external API.

Initially there are NO logs from the UserInfo component because it's not yet mounted (as the userId state is initially set to an empty string).


Let's search for a user with userId of 1.

Logs:

Before useEffect
After useEffect
Inside useEffect
Before useEffect
After useEffect

So, when you hit the search button, setUserId is called which causes a re-render and now for the first time the UserInfo component is rendered.

The UserInfo function is called and from there we have our first log "Before useEffect".

💡 Observation: Notice that the second log that we have is not "Inside useEffect" but its "After useEffect"

🚀 This is because useEffect runs asynchronously after React has finished rendering.

So, after the "After useEffect" log, React renders <p>Loading...</p> and then React calls the useEffect function.

Inside useEffect we get the "Inside useEffect" log printed.

Then we have setError(null), before you proceed further just think for a moment will this cause a re-render?

The answer is NO and its because error is currently null and its being set to null, which means the error state has not changed, so a re-render is not required (React is Smart folks!).

So, we move past setError(null) and then fetchUser is called, and once the data is fetched from the API, we call setUser with that data (assuming there's no error) which causes a re-render and because of which we get our last two logs printed.

Before we proceed to the next section I want you add one more log to the UserInfo component as shown below:

console.log("%cAfter useEffect", "color: coral");

if (error) return <p>{error}</p>;
console.log("%cAfter error check", "color: crimson");

if (!user) return <p>Loading...</p>;

if (user) return <pre>{JSON.stringify(user, null, 2)}</pre>;
Enter fullscreen mode Exit fullscreen mode

Let's now search for a user with userId of a.

I don't want you to observe any logs here because its same as before (except from the one we just added).

We did this because we wanted to set our error state to something other than null.

(Clear the console before moving to the next section)


Let's again search for a user with userId of 1.

There are a lot more logs this time, let's knock them one by one.

Logs

Before useEffect
After useEffect
Inside useEffect
Before useEffect
After useEffect
After error check
Before useEffect
After useEffect
After error check

We already know why we have the first two logs, but notice that we didn't print the "After error check" log and this is because we still have the error state set to null, which again emphasises the same fact that useEffect is not called before React finishes rendering.

So, React first renders <p>{error}</p> and after that it calls the useEffect hook and we get the third log "Inside useEffect".

Now, this time when setError(null) is called, it will cause a re-render because error is not null currently.

So, because of the change in error state we get logs 4, 5 and 6. And this time since error is no more truthy, therefore we log "After error check".

And finally once the data is fetched from the API, we call setUser(data) which causes a re-render and we get the last three logs.




That's It! 🤘

Hope, you found this useful and learned something new. Let me know your thoughts in the comments.

Discussion (0)