A Beginners Guide to Mastering the useEffect Hook in React
Understanding the useEffect hook like a pro
Hooks have come to play a major role in React component development, specifically in functional components as they have completely replaced the need for class-based components, which was initially the traditional go-to method for creating components that required states or handling life-cycle methods or side effects.
Amongst the several hooks introduced, the useEffect hook is easily one of the most important hooks. However, it can be very tricky to understand, especially to newer React developers just getting into React or migrating from class-based components to function components.
In this article, we’ll be taking a closer in-depth look at the useEffect hook and by the end of it, you'll have a better understanding of its intricacies.
What is the useEffect Hook?
As defined in React's useEffect docs, “The Effect Hook lets you perform side effects in function components”. Perhaps you are wondering, just what is a side effect? Especially in the context of React components. A "Side Effect" is not a react-specific term, but generally the behavior of functions.
In a function, a side effect occurs when it attempts to modify anything outside its body or the scope of its context. For example, changing the value of a global variable. A typical example of this behavior in React is when a component makes a network call (communicates with a third party) to get or send data, it is considered a side effect.
Why do we Need useEffect?
Imagine you had a component in your React application that is supposed to fetch a list of users when rendered to the DOM, you’d want to do this immediately after the component is rendered in the DOM. The need to monitor and manipulate a component to perform operations based on the phase of the component is what brought about the component lifecycle.
Every component has 3 lifecycles:
- Mounting - When a component is injected into the DOM.
- Updating – When the internal state of a component changes and it re-renders to reflect these changes.
- Un-mounting - And finally when a component is removed from the DOM.
In traditional class-based components, there are specific methods responsible for running operations in each respective component lifecycle phase. But in a function component, the useEffect hook is single-handedly responsible for handling these lifecycle methods.
Side Effects without the useEffect Hook
Before diving in to take a detailed look at how the useEffect hook handles these life cycle phases, let’s try to fetch a user object data from a server without using the useEffect hook to see exactly what happens.
import { useState } from "react";
function App() {
const [user, setUser] = useState(null);
// Side effect
fetch("https://jsonplaceholder.typicode.com/users/1")
.then(res => res.json())
.then(data => {
setUser(data);
console.log(data);
})
return (
<div className='App'>
<h1>Hello {user?.name}</h1>
</div>
);
Code Explanation:
// We start by creating a user state using useState with a null value.
// After which, we create a side effect to fetch data from a server that returns a user object.
// From the JSON response, we extract the user object, store it in our state, and then log it to the console
// Finally we render the "Hello" text with the user’s name if it exists (hence the optional chaining "?" operator).
Output👇
When this component gets executed and mounts, you will notice an oddity. The user that was fetched from the server and logged to the console once actually keeps getting logged to the console over and over again without end.
This odd behavior is a result of how React works under the hood, which is, when a state (i.e. “user” in our case) in a component changes, it re-evaluates the component and its variables and re-renders that component with the updated change. Simple right? Well, that is exactly why we are having this problem.
When our component gets mounted initially, React evaluates this component’s state and associated variables/values. At this time the user state is null, and then a fetch request is made to get data from a server. When this data is returned, it is stored in the user state that was previously null and logged to the console.
React notices this change in state and re-evaluates the function (executes the function again) to update all variables and templates to reflect this change, but that is like hitting a refresh button on the component.
The entire code is executed a second time, triggering another fetch request to the server, which returns the same user object and updates the state with the new user data, along with logging it to the console again, which in turn triggers React to re-evaluate the component and re-renders it again… hence we are caught in an infinite loop.
This is why we need the useEffect hook, it can tell React to run a function only once and once alone, or to run the function whenever there is a change in the state.
How the useEffect Hook Works
Now that we know why the useEffect hook is needed, let's dive into using it to perform this same side effect. To use the useEffect hook, it will have to be imported into our component like every other React hook.
import { useEffect } from "react";
This hook receives two-argument, one a callback function that is to be called initially when the component renders/mounts... this is where you want to perform whatever operation you wish to carry out like a fetch request. The second argument is what is called a dependencies array (more on this later) as shown below.
useEffect(callback, [dependencies]);
Let's rewrite the code we wrote earlier using a useEffect hook and see what happens when only one argument is passed to the useEffect hook (without the dependencies array).
export default function App() {
const [user, setUser] = useState(null);
// Side effect
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/users/1")
.then((res) => res.json())
.then(data) => {
setUser(data);
console.log(data);
});
});
return (
<div className='App'>
<h1>Hello {user?.name}</h1>
</div>
);
}
We are basically making the same fetch request this time, but we are doing it inside a useEffect hook. Notice we have not added the second argument which is the dependencies array? The output would be the same as the initial loop encountered.
Why? Because the useEffect hook runs after every render by default which results in the same infinite loop of fetching data, updating the state, and re-rendering the component. This happens simply because we did not provide a dependency array to the useEffect hook, which is crucial to having the useEffect hook behave as intended.
The Dependencies Array [ ]
As its name implies, the dependencies array is a mandatory array passed to the useEffect hook as a second argument. This array allows you to specify the condition necessary for the useEffect hook to re-run or re-render. Passing the useEffect hook an empty array is the same as saying run this side effect exactly once and only once when this component renders, regardless of any state changes.
But when one or more values are passed into the array, you're saying run the side effect once initially after the component renders, then re-run this side effect whenever the state of the variables within the array changes or gets updated across re-renders.
So let's pass in an empty array to our useEffect hook as a second argument.
export default function App() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/users/1")
.then((res) => res.json())
.then(data) => {
setUser(data);
console.log(data);
});
}, [] );
return (
<div className='App'>
<h1>Hello {user?.name}</h1>
</div>
);
}
Now when we render this component, the side effect in the useEffect hook would run exactly once after the component renders, and even after fetching the data and updating the state, it would not trigger a re-run or an infinite loop.
As you can see, the user object is only ever console logged once and once only when the component mounts, because we passed an empty array to the useEffect hook.
Now that we know the behavior of the useEffect with an empty array, let's take a look at its behavior when we have an actual dependency in the array. We'll refactor our code to have the fetch URL stored in a "url" state and have a button that when clicked, changes the URL from "user/1" to "user/2".
export default function App() {
const [user, setUser] = useState(null);
const [url, setUrl] = useState("https://jsonplaceholder.typicode.com/users/1");
useEffect(() => {
fetch(url)
.then((res) => res.json())
.then((data) => setUser(data));
}, [url]);
const handleClick = () => setUrl("https://jsonplaceholder.typicode.com/users/2")
console.log(user);
return (
<div className='App'>
<h1>Hello {user?.name}</h1>
<button onClick={handleClick}>
Change User
</button>
</div>
);
}
Because we have passed this URL to the useEffect hook as a dependency, the useEffect hook runs once initially after the component renders, getting the user1 data.
Then React monitors the “url” state placed in the dependencies array for any change that occurs to it. When we click on the “Change User” button, the value of the URL changes which React notices and triggers the useEffect hook to re-run the fetch request again with the new URL value. Hence a new user2 is obtained.
As you can see, the user has changed from the first user to the second newly fetched user.
The Tricky Bits of The Dependencies Array
However, the dependencies array works differently when a reference data type (like an array, object, function, etc.) is passed into the dependencies array, this triggers the same infinite loop as before. This happens because every time our component is re-evaluated, every regular JavaScript variable or function declared in it is recreated and stored elsewhere in memory.
Then React compares the ones from the previous render to the newly created ones. The problem with this is that React doesn't compare the variables themselves or the value they hold but rather the reference to their exact location in memory. And because they are all recreated during each re-evaluation, they are not seen as the same because they now point to different places in memory.
This is why an infinite loop is triggered as the pointers to these reference data types change during re-evaluation. The solution to this depending on the exact data type is to wrap it in a useState() or a useRef() hook before passing the new value to the dependencies array. But if the reference type is a function, we wrap it in a useCallback() hook.
The UseEffect Cleanup Function
The useEffect cleanup function is the final step in properly implementing a side effect in React. Some effects typically leave behind resources that need to be cleared from memory before the component leaves the screen. It is therefore important that we clean these side effects for performance reasons such as avoiding memory leaks in our React applications.
For example, it might be that we have a subscription to an external data source, a timeout function, an event listener, or other side effects that we need to clear from memory by cleaning it up once they are no longer required.
Imagine we set a count state that gets updated by a timer at intervals from a useEffect hook.
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
// increment count by 1 every second
setCount((count) => count + 1);
console.log(count);
}, 1000);
}, []);
If the component gets unmounted, both the component and its state are destroyed. However, the setInterval function will keep running in memory and try to update the count variable that no longer exists. This would result in a memory leak error.
Note: Typically, a component gets unmounted when we navigate to a new route/page in our application, where the current component is removed for the new one to be mounted.
In order to clean up a side effect, we need to return a callback function from the useEffect hook.
useEffect(() => {
// Side Effect
return () => {
// Side Effect Cleanup
}
}, []);
It is within this function that we can perform a cleanup logic, in our case, using clearInterval to clear or count side effects.
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
// Side Effect
let interval = setInterval(() => {
setCount((count) => count + 1);
console.log(count);
}, 1000);
// Clear Side Effect
return () => clearInterval(interval);
}, []);
Two things to note about the Cleanup function and how it works:
- After the initial render, the cleanup function gets invoked on subsequent renders to clear any previous side effect before the next side effect can be executed.
- Secondly, the cleanup function also gets invoked when a component is unmounted to clean up any side effects.
It is worth noting that the useEffect cleanup is not always required. It is only required in specific cases, like when you need to prevent repeating side effects when your component is unmounted.
Conclusion
To conclude, the useEffect hook is a very powerful hook that replaces the need for life cycle methods in functional components. In order to become a top-level React developer, one must master the useEffect hook, the need for its dependencies array, and best practices such as cleaning up after a side effect.
I hope this article helped you become a better React developer. Thanks for reading.