Understanding the flow of React's useEffect hook
Som Shekhar Mukherjee
Posted on August 19, 2021
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);
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);
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);
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"));
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>;
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.
Posted on August 19, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024