Skip to main content
Version: WIP

📓 3.4.0.3 Running Side Effects with the useEffect Hook

We already know how to use useState to manage state inside a function component. Now let's learn about the companion hook for running side effects: useEffect.

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! We'll learn it by building a small standalone example app, then later apply it to Help Queue when we connect to Firebase.

Setting Up an Example App​


To explore useEffect, we'll build on the counter-app from the Introduction to State lesson in React Fundamentals. Recreate it now if you don't have it handy from before:

npm create vite@latest counter-app -- --template react-ts
cd counter-app
npm install
npm run dev

As always with a new Vite project, open tsconfig.app.json and add "strict": true to compilerOptions if it isn't there already. With the dev server running, replace the code in src/App.tsx with:

src/App.tsx
import Counter from './Counter';

function App() {
return (
<Counter />
);
}

export default App;

And here's src/Counter.tsx, the same as in the Introduction to State lesson:

src/Counter.tsx
import { useState } from 'react';

function Counter() {
const [count, setCount] = useState(0);

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

export default Counter;

Note that we don't need to import React itself. Vite's React template uses the automatic JSX runtime, which means JSX works without an import React from 'react' line. We only import the specific hooks we use, like useState.

For the useEffect examples in this lesson, we'll need a second state variable so we can demonstrate how the dependency array works. Add hidden state and a second button:

src/Counter.tsx
import { useState } from 'react';

function Counter() {
const [count, setCount] = useState(0);
const [hidden, setHidden] = useState(false);

return (
<>
{hidden ? <h1>Count Hidden</h1> : <h1>{count}</h1>}
<button onClick={() => setCount(count + 1)}>Count!</button>
<button onClick={() => setHidden(!hidden)}>Hide/Show</button>
</>
);
}

export default Counter;

Neither useState call needs a type argument here. TypeScript infers number from the initial value of 0 and boolean from the initial value of false.

Now we have two state variables to work with. We'll also add a Timer component later in the lesson.

useEffect​


We should use the useEffect hook when we want to run side effects in any of the following cases:

  • After our component is first rendered
  • When a specific state variable or prop changes
  • After every re-render of our component

The last case is the default behavior for the useEffect hook. Let's take a look.

Open the Counter component, and update the code as shown here:

src/Counter.tsx
import { useState, useEffect } from 'react';

function Counter() {
const [count, setCount] = useState(0);
const [hidden, setHidden] = useState(false);

useEffect(() => {
console.log("effect!");
});

return (
<>
{hidden ? <h1>Count Hidden</h1> : <h1>{count}</h1>}
<button onClick={() => setCount(count + 1)}>Count!</button>
<button onClick={() => setHidden(!hidden)}>Hide/Show</button>
</>
);
}

export default Counter;

We import useEffect alongside useState at the top of the file, then call it inside the component. It takes a callback function as its argument, and that callback is the "effect" that runs. If we run our app and click the Count! or Hide/Show buttons, we'll see "effect!" logged each time, because every state change causes a re-render, and every re-render triggers the effect.

That's the default behavior: useEffect runs after every render.

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 = String(count);
});

With document.title = String(count), we're updating the <title> tag of our HTML to the current value of count. Now if we click the Count! or Hide/Show buttons, we'll see "effect!" logged each time and the page title will update to match count.

note

Notice that we wrap count in String() rather than assigning it directly. document.title is typed as a string, and count is a number, so document.title = count is a type error:

Type 'number' is not assignable to type 'string'.

This is a small example of TypeScript catching a mismatch that plain JavaScript would have quietly converted for us. String(count) makes the conversion explicit, which is exactly what we want.

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 count value when count changes. Right now, it will get updated every re-render, which is caused by any change in state, including clicking the Hide/Show button.

React developers have a solution for this, and this is what it looks like:

  useEffect(() => {
console.log("effect!");
document.title = String(count);
}, [count]);

Notice that we've added a second argument to our useEffect hook: [count]. 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 count as our dependency, we're specifically directing useEffect to run the effect only if the value of count changes. If count does not change, the useEffect hook will skip the effect.

We can test this out. Now if we run our counter-app 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 means the effect only runs when one of those dependencies changes, not after every render.

Only Running the Effect Once​

We can tell the useEffect hook to run its effect only once by passing in an empty dependency array:

  useEffect(() => {
console.log("effect!");
document.title = String(count);
}, []);

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 counter-app 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 counter-app application to import and render a new Timer component. Here's the code:

src/App.tsx
import Counter from './Counter';
import Timer from './Timer';

function App() {
return (
<>
<Counter />
<Timer />
</>
);
}

export default App;

Note that we now return two elements from App, so we wrap them in a fragment (<>...</>). A component can only return a single root element, and the fragment groups our two components together without adding an extra element to the DOM. When there's only one element to return, like the single <Counter /> we had before, no fragment is needed.

Next, let's create a new file called Timer.tsx, also in the src folder. Here's the code that we'll add inside of Timer.tsx to create the new Timer component:

src/Timer.tsx
import { useState, useEffect } from 'react';

function Timer() {
const [isActive, setIsActive] = useState(false);
const [timer, setTimer] = useState(0);

useEffect(() => {
let interval: number | undefined;

if (isActive) {
interval = setInterval(() => {
setTimer(timerState => timerState + 1);
}, 1000);
}

return () => clearInterval(interval);
}, [isActive]);

return (
<>
{isActive ? <h1>{timer}</h1> : <h1>Timer Stopped</h1>}
<button onClick={() => setIsActive(!isActive)}>Start/Stop</button>
</>
);
}

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 and isActive, 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: number | undefined;

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.

This useEffect hook runs whenever isActive changes. If isActive is true (if the user started the timer), we set up an interval that increments the timer value every second.

Notice the annotation on our interval variable: let interval: number | undefined;. In the browser, setInterval() returns a number that identifies the interval, and that's the value we hand to clearInterval() later. We have to annotate it here because we declare interval before we know whether we'll assign anything to it. If isActive is false, we never call setInterval() at all, so interval stays undefined. Writing number | undefined describes both possibilities, and clearInterval() accepts either one.

But how do we stop the timer? That's where the optional useEffect clean up mechanism comes in handy. To use this mechanism, we need to return a function from useEffect's callback function:

  useEffect(() => {
// ...same effect body as before

return () => clearInterval(interval);
}, [isActive]);

The function we return is called a cleanup function. React calls it when the component unmounts (that is, when it is removed from the page), and also before re-running the effect on any subsequent render. In the case of our Timer, that means the interval is cleared whenever isActive changes, and then a new one is started only if isActive is true.

note

One thing worth noting: we're passing a callback function into setTimer rather than a value directly.

The normal way to update state is to pass the new value directly into the state setter, like this:

setTimer(timer + 1)

But that would require us to list timer as a dependency in our useEffect hook, which would cause the effect to re-run every second (because timer changes every second). Instead, we can pass a function into the state setter, like this:

setTimer(timerState => timerState + 1)

When we pass a function to a state setter, React passes the current state value in as the argument (here we name it timerState). This lets us update timer without listing it as a dependency in the effect, which means the effect won't re-run every second just because timer changed.

We don't have to annotate timerState either. Because timer was initialized with 0, TypeScript knows this state holds a number, so it infers that timerState is a number and that our function has to return one. Try changing the body to timerState + "1" and watch TypeScript object.

For more detail on this pattern, see Updating state based on the previous state in the React docs.

Resources and Next Steps​


Whew! We've covered a lot in this lesson. If anything about useEffect isn't feeling entirely clear, know that we'll be using it again when we connect Help Queue to Firebase, and seeing it applied to a real problem will help it click. Before we start in on that, we're going to wrap up our introduction to hooks by reviewing the rules of hooks and how to write a custom hook.

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: