📓 4.4.0.3 Running Side Effects with the useEffect Hook
As we learned in the last lesson, hooks allow us to use React state and lifecycle features in function components. Historically, these features were only available in class components. Now that we know how to use state in our function components with the useState
hook, let's learn how to perform side effects with the useEffect
hook.
Keep in mind that a side effect is not a React-specific term; instead, it's a way of describing functions in general. A function has side effects when it changes something outside of its own scope. Often this looks like making a network request to an API, but this also includes changing the value of a variable that exists outside of the scope of the function. Another good example of a side effect is updating a value in the DOM.
As we know, when a function does not have side effects, we call it a pure function, where for any given input, you can always expect the same output. These functions are predictable, easy to test, simple to reason about, and easy to maintain and refactor.
With that review, let's dive into what the useEffect
hook can do for us! For the examples in this lesson, we'll add to our intro-to-hooks
application, by expanding the functionality of our Counter
component and creating a brand new Timer
component. Later on when we connect our Help Queue application to a NoSQL database via Google's Firebase, we'll use the useEffect
hook to fetch data for us.
useEffect
We should use the useEffect
hook when we want to run code in any of the following cases:
- After our component is first rendered. This corresponds to the
componentDidMount
lifecycle method. - When a state variable in our component changes. This corresponds to looking at the
prevState
variable available in thecomponentDidUpdate
lifecycle method to determine if state has changed, and to only make an update if it has. - After every re-render of our component. This corresponds to the
componentDidUpdate
lifecycle method.
The last case is the default behavior for the useEffect
hook. Let's take a look.
Open the Counter
component, and add this code:
import React, { useState, useEffect } from 'react';
function Counter() {
const [counter, setCounter] = useState(0);
const [hidden, setHidden] = useState(false);
useEffect(() => {
console.log("effect!");
});
return (
<React.Fragment>
{hidden ? <h1>Count Hidden</h1> : <h1>{counter}</h1>}
<button onClick={() => setCounter(counter + 1)}>Count!</button>
<button onClick={() => setHidden(!hidden)}>Hide/Show</button>
</React.Fragment>
);
}
export default Counter;
First, we import the useEffect
hook at the top of the file, and then we call it within our Counter
component. There's a couple things to note about the useEffect
hook:
useEffect
is first run after the first render of the component.- Without further configuration,
useEffect
runs after every re-render of the component. useEffect
takes a callback function as an argument, which acts as our "effect". This function is called every time theuseEffect
hook runs.useEffect
doesn't return anything.
If we run our app and click on our Count! and Hide/Show buttons, we'll see our "effect!"
message logged each time. That's because every time we use setCounter()
or setHidden()
to update a state variable, our component re-renders to display the new state value, and every time our component re-renders, the useEffect
hook calls the function we pass into it.
That's the basics of the useEffect
hook. As noted earlier, the default configuration of the useEffect
hook matches the functionality of two class component lifecycle methods: componentDidMount
, since useEffect
runs after the first render, and componentDidUpdate
, since useEffect
runs after every re-render of our component.
Let's try adding something other than a console.log()
. Update your useEffect
hook to perform the following side effect:
useEffect(() => {
console.log("effect!");
document.title = counter;
});
With document.title = counter
, we're updating the value of the <title>
tags of our HTML to the current value of our counter. Now if we click on our Count! and Hide/Show buttons, we'll see our "effect!"
message logged each time and the title will match the current value of our counter
.
It's important to note that the function we pass as an argument into useEffect
(a callback function) is newly created each time useEffect
is run. This is the argument we are passing into useEffect
:
() => {
console.log("effect!");
document.title = counter;
}
This allows the useEffect
hook to access the most up-to-date value of our counter
state variable.
Skipping Effects
As is, we can optimize our code in the useEffect
hook. How? Well, we really only need to update the title of our HTML document to the new counter value when the value of our counter changes. Right now, it will get updated every re-render, which is caused by any change in state, including the showing and hiding of our counter.
React developers have a solution for this, and this is what it looks like:
useEffect(() => {
console.log("effect!");
document.title = counter;
}, [counter]);
Notice that we've added a second argument to our useEffect
hook: [counter]
. This second argument is called a dependency array, and it can contain one or more state variables or props within it. When we add a dependency array to our useEffect
hook, we're saying that whether our effect should run depends on whether the value of the state variables in our dependency array have changed.
When we add counter
as our dependency, we're specifically directing useEffect
to run the effect only if the value of counter
changes. If counter
does not change, the useEffect
hook will skip the effect.
We can test this out. Now if we run our intro-to-hooks
application and click on our Count! and Hide/Show buttons, we'll only see our "effect!"
message logged when we click on the Count! button.
As noted above, adding a dependency array to the useEffect
hook performs the same functionality as comparing prevState
with current state in a componentDidUpdate
lifecycle method:
componentDidUpdate(prevProps, prevState) {
if (prevState.counter !== this.state.counter) {
document.title = counter;
}
}
Only Running the Effect Once
You can tell the useEffect
hook to run its effect once by passing in an empty dependency array:
useEffect(() => {
console.log("effect!");
document.title = counter;
}, []);
In this case, we're saying that our effect does not depend on the change of any state variables or props in our component, and it should only run once, after the first render.
We won't use this in our intro-to-hooks
application now, but you can try it out if you like. Later on we'll use an empty dependency array to set up a subscription to our NoSQL database (provided by Firebase) once, after the first render of our component.
Performing Clean-Up Tasks
Let's look at one last example with the useEffect
hook to understand how we can perform clean up tasks. In this example, we'll create a timer that counts up from 0 every second. We'll also be able to pause the timer as well!
First, let's update our App
component in the intro-to-hooks
application to import and render a new Timer
component. Here's the code:
import './App.css';
import Counter from './Counter';
import Timer from './Timer';
function App() {
return (
<div className="App">
<Counter />
<Timer />
</div>
);
}
export default App;
Next, let's create a new file called Timer.js
, also in the src
folder. Here's the code that we'll add inside of Timer.js
to create the new Timer
component:
import React, { useState, useEffect } from 'react';
function Timer() {
const [isActive, setIsActive] = useState(false);
const [timer, setTimer] = useState(0);
useEffect(() => {
let interval;
if (isActive) {
interval = setInterval(() => {
setTimer(timerState => timerState + 1)
}, 1000
)}
return () => clearInterval(interval);
}, [isActive]);
return (
<React.Fragment>
{isActive ? <h1>{timer}</h1> : <h1>Timer Stopped</h1>}
<button onClick={() => setIsActive(!isActive)}>Start/Stop</button>
</React.Fragment>
);
}
export default Timer;
We're doing quite a few things in our new Timer
component, some of which should look familiar:
- We're using two state variables,
timer
andisActive
, to track the value of our timer and whether it is active or not. - We include a button to start and stop the timer.
- We use a
useEffect
hook to set up an interval when our timer is active, and to remove it when our timer is stopped.
Let's take a closer look at our useEffect
hook.
useEffect(() => {
let interval;
if (isActive) {
interval = setInterval(() => {
setTimer(timerState => timerState + 1)
}, 1000
)}
return () => clearInterval(interval);
}, [isActive]);
Notice that we have one dependency that we've passed into the useEffect
dependency array: [isActive]
. That's because we want our effect to run only when the value of isActive
changes.
When we first set up the interval with JavaScript's setInterval
function, we specify that we only want to create a new interval if the timer is started, or in code, if isActive
is true
. The interval itself specifies that we should call the setTimer
function to update the timer
state every second.
But how do we stop the timer? That's where the optional useEffect
clean up mechanism comes in handy! To use this mechanism, we simply need to return a function from the callback function we pass into the useEffect
hook:
useEffect(() => {
...
return () => clearInterval(interval);
}, [isActive]);
Here we return an anonymous arrow function that makes use of an implicit return. Note that we can rewrite this return statement as follows:
useEffect(() => {
...
return function() {
clearInterval(interval);
}
}, [isActive]);
And what does this function do? It calls JavaScript's built-in clearInterval
function to clear the interval we created.
When we return a function from a useEffect
hook, it will run this function when our component unmounts to clean up our effects. However, since React runs effects every re-render (unless we specify otherwise), React also performs this clean up before re-running the effect on a subsequent render.
In the case of our Timer
component, we've specified that our effect should only run when the isActive
state variable changes. So, anytime isActive
changes our interval will be cleared and then re-created only if isActive
is set to true
.
When a Dependency Changes too Often
You may have noticed something else that's new in the useEffect
hook that we've created for our Timer
component. Notice that when we create the interval, we're passing in a function when we call setTimer
to update the value of the timer
state:
useEffect(() => {
let interval;
if (isActive) {
interval = setInterval(() => {
// Notice the argument we pass into setTimer
setTimer(timerState => timerState + 1)
}, 1000
)}
return () => clearInterval(interval);
}, [isActive]);
This is in contrast to what we've done up until now, which is to directly pass in a new value for the state variable. For our timer
state variable, this would look like setTimer(timer + 1)
.
Note that the arrow function makes use of an implicit return, which can be hard to read and reason about. If you prefer, the same arrow function above can be re-written as follows:
setTimer(timerState => {
return timerState + 1
})
So why pass in a callback function? It allows us to use the timer
state, without having to pass in timer
as a dependency to our useEffect
hook. To understand this, let's look at the alternative.
If we don't use a callback function, we would have to pass in the timer
state variable as a dependency like so:
useEffect(() => {
let interval;
if (isActive) {
interval = setInterval(() => {
// Notice the updated code below.
setTimer(timer + 1)
}, 1000
)}
return () => clearInterval(interval);
}, [isActive, timer]); // Notice the new dependency.
While our timer will work as expected, the issue with this code is that it's less efficient because our effect will be called every time the value of the timer
state variable changes! That's every second. The solution here is to use the option to pass in a function to setTimer
instead of a new state value:
setTimer(timerState => timerState + 1)
With this callback function, our useState
hook will handle passing in the timer
state as the value for the timerState
parameter. In turn, this code will increment timer
state by 1. What's more, we don't have to pass in timer
to the useEffect
dependency array. This means that our effect will only be called when isActive
changes, which is exactly what we want.
This last topic we just covered is a slightly more of an advanced topic with using the useEffect
hook, but a pertinent one none-the-less. If you want to read more about it, visit this entry called What can I do if my effect dependencies change too often?.
Resources and Next Steps
Whew! We've covered a lot in this lesson. If anything about the useState
or useEffect
hook isn't feeling entirely clear, know that we'll be using both of these hooks again when we implement them in our Help Queue application. Before we start in on the Help Queue, we're going to wrap up our introduction to hooks by reviewing the rules of hooks, their benefits, and how you should use them in React applications.
Note that it's normal for unexpected things to happen when we're first learning about hooks and how to implement them. If you run into issues, you should always start by referencing the React docs on Hooks.
For docs specifically on the useEffect
hook, visit these links: