React Effects

Chapter 19 27 mins

Learning outcomes:

  1. What are effects in React
  2. The useEffect() hook
  3. What is the dependency array
  4. What is the cleanup function

Introduction

In the React Components chapter, we learnt that all the logic inside components in React is divided into three categories: rendering, events and effects. Rendering contains all the logic related to the rendering of elements, while events account for all event handler functions.

So where and what are effects then?

Well, that's what this chapter is all about. In this chapter, we shall understand what exactly are effects in React and how to use them to perform side effects in our components.

We'll explore the useEffect() hook, and see how it really works. We'll also take a look over useEffect()'s dependency array and the idea of the cleanup function to call when the underlying component is cleared up.

The useEffect() hook represents an extremely useful and crucial feature to understand in React, and so is this chapter. So without further ado, let's begin discovering effects.

What are effects?

As mentioned before, based on the design ideology of React, it's not desirable to perform side effects directly in components. We either do that in event handlers or we do it in the form of effects.

Now before we understand what effects are, there's a good reason to ask why do we even need them. Why can't call side effects be performed from within event handlers in React?

Well, it's not required to look very far off to find an answer to this.

Suppose we want to set up a 3s timer the moment a component is rendered on the document. As you can clearly reason, there's no single event that represents the 'rendering' action, and so we can't perform this task using any event handler whatsoever. This is essentially where effects come in.

Coming back to the definition of effects:

An effect in React is a means of performing a side effect once a component is done rendering.

To be more specific, an effect gets executed right after the browser repaint routine (which happens after the reflow).

Because an effect occurs right after the repaint, it has access to all the latest pieces of data, which includes the underlying component's state, refs; and also includes the properties of the DOM.

react.dev puts it this way:

Effects let a component connect to and synchronize with external systems. This includes dealing with network, browser DOM, animations, widgets written using a different UI library, and other non-React code.

'Connecting to' and 'synchronizing with external systems' refers to the notion of performing side effects, such as sending HTTP requests, or directly mutating the DOM, or working with code outside React.

The term 'Effects' in React's documentation

The latest documentation of React, that explains this concept in the Synchronizing with Effects page, uses the term 'Effects', with the capitalization, to refer to the concept as a distinct one from the more general idea of 'side effects' in React.

We don't use this capitalization to keep the text simple and prevent any ambiguity from arising in case we somehow forget to capitalize the word 'effects'.

It's important to remember that the term 'effects' in this chapter refers to side effects tied to the rendering of a component, whereas the term 'side effects' refers to the more general idea, which also takes into account event handlers.

Now the question is: how to set up effects in React?

Well, that's done using the useEffect() hook.

The useEffect() hook

The useEffect() hook is used to create an effect in React. Perhaps, after useState(), the second-most used hook in React is useEffect() — it's that useful!

The way useEffect() works is pretty basic. Here's its signature:

useEffect(callback, dependencies)

The first callback argument specifies a callback function to execute after the underlying component is done rendering. As stated before, this callback function executes after the browser repaint routine.

The second optional dependencies argument specifies the dependency list for the effect. We'll explore this in more detail later on in this chapter.

For now, let's consider an example of a React effect.

In the following code, we set up a 3s timer the moment the App component is rendered. Once this timer completes, an alert is made saying 'Hello' to the user:

import React, { useEffect } from 'react';

function App() {
   useEffect(() => {
      setTimeout(() => {
         alert('Hello from useEffect()!');
      }, 3000);
   });

   return (
      <h1>Wait for 3s for the greeting.</h1>
   );
}

Live Example

Let's consider another example:

In the following example, we bring back the fetch() call from the React Events chapter, this time as a side effect in useEffect() instead of as a side effect in an event handler:

import React, { useState, useEffect } from 'react';

function App() {
   const [info, setInfo] = useState();

   useEffect(() => {
      (async () => {
         const data = await fetch('https://api.github.com/users/codeguage-code');
         setInfo(await data.json());
      })();
   })

   return (
      {info ? (
         <div className="info">
            <p>Name: {info.name}</p>
            <p>Bio: {info.bio}</p>
         </div>
      ) : (
         <p>Loading...</p>
      )}
   );
}

Live Example

There's an important thing to note here: an async function is nested inside the callback to useEffect() instead of the callback itself being an async function.

This is due to the nature of the useEffect() hook, i.e. it expects the return value of the callback function to be another function; when we provide an async function as the callback function, as you may know, JavaScript implicitly returns a promise from the function, and this interferes with React's expectation of a function returned from the callback.

Likewise, if we want to use an async function inside useEffect()'s callback, we have to nest it as a separate function inside the callback and then invoke it.

One simple way to do so is to create the async function as an IIFE, as we did above.

Let's consider a third example, this time setting up a keydown event handler on window in order to showcase the name of the key pressed using the code property of the fired event.

Here's the JavaScript code:

import React, { useEffect, useState } from 'react';

function App() {
   const [key, setKey] = useState();

   useEffect(() => {
      window.addEventListener('keydown', (e) => {
         setKey(e.code);
      });
   });

   return (
      key ? (
         <div class="key">{key}</div>
      ) : (
         <div class="light">No key entered</div>
      )
   );
}

And here's the CSS to add some nice styles to the program:

body {
   text-align: center;
   font-size: 40px;
   font-family: sans-serif;
   margin: 50px
}

.light {
   opacity: 0.4;
}

.key {
   font-weight: 600;
   font-family: monospace;
}

Initially, as the program loads, the value of key is undefined, and, likewise, the .light element gets rendered. But as soon as a key is pressed, the keydown event fires, and causes the key state value to be updated. Thereafter, the App component renders a .key element, containing the name of the key pressed.

Live Example

Pretty amazing!

Now one thing worthwhile discussing at this stage is that, in all of the examples shown thus far, note that the callback provided to useEffect() gets executed each time the underlying component gets rendered.

As you may agree, this can be problematic, especially if the callback performs a side effect that must be executed only once.

For instance, the third example above calls addEventListener() to register a keydown event handler on window. If the useEffect() callback here executes again, we'd get another such event handler registered on window, which is contrary to what we want.

Let's add a simple console.log() statement inside the handler of keydown above to see when and how many times does the handler get invoked:

import React, { useEffect, useState } from 'react';

function App() {
   const [key, setKey] = useState();

   useEffect(() => {
      window.addEventListener('keydown', (e) => {
console.log('keydown listener registered'); setKey(e.code); }); }); return ( key ? ( <div class="key">{key}</div> ) : ( <div class="light">No key entered</div> ) ); }

Live Example

Now, if we try pressing a key for the very first time after the program loads, we get one console log. So far, so good.

Viewing the console logs made on first key press.
Viewing the console logs made on first key press.

But as soon as we press another key, we get two further logs, NOT one:

Viewing the console logs made on first second press.
Viewing the console logs made on second key press.

And if we press yet another key, we get three further logs:

Viewing the console logs made on third key press.
Viewing the console logs made on third key press.

Clearly, each time a different key is pressed, the number of logs is one greater than the number of logs made by the previous key press.

But why?

Well, the reason is simple — useEffect()'s callback executes each time App gets re-rendered by virtue of a different key being pressed, and that effectively registers a whole new keydown listener on window, with the previous one(s) being intact.

So the second time when we press a key, we effectively get two different keydown handlers executed; the third time, we get three different handlers executed; and so on and so forth.

To solve this issue, we just have to prevent the callback function from executing again and again with each re-render.

And in order to do so, we ought to understand the second argument provided to useEffect(), i.e. the dependency array. Let's see what exactly is it.

The dependency array

As the name suggests, the dependency array of useEffect() specifies a list of dependencies for the effect.

These dependencies govern when exactly would the effect be put into execution. Without a dependency list, as we saw above, the callback function gets invoked every single time with a new re-render, as we just saw above.

An empty dependency array

However, with an empty array, it gets called only once. After that, the callback never gets called again, thanks to the empty dependency array.

In the following example, we rewrite the previous code where we defined a keydown event handler on window, passing an empty dependency list to useEffect():

import React, { useEffect, useState } from 'react';

function App() {
   const [key, setKey] = useState();

   useEffect(() => {
      window.addEventListener('keydown', (e) => {
         console.log('keydown listener registered');
         setKey(e.code);
      });
   }, []);

   return (
      key ? (
         <div class="key">{key}</div>
      ) : (
         <div class="light">No key entered</div>
      )
   );
}

As the dependency array is present but empty here, the given useEffect() callback won't get called after the first render of App. Thus, we can be rest assured that window has only one registered keydown handler at any point in time, and thus each key press would make just a single log.

Live Example

In the link above, as before, try pressing three different keys and then witnessing the logs made with each one.

The dependency array is an amazing idea!

A dependency array with an item

Remember, however, that the dependency array doesn't always have to be empty — we could populate it with given values as well. In particular, we need to put those values in it that must change in order to mandate an execution of the provided callback function.

Let's consider an example.

Suppose we have a text input element, with an initial value to begin with. The input element is disabled; its value can only be modified by clicking on an Edit button. This button serves to put the whole input container in 'edit' mode; once in this mode, the Edit button gets replaced by a Save button that serves to change the container into 'disabled' mode.

The most important thing to note is that when the edit button is clicked, the input element is focused automatically. The user doesn't have to manually click the input in order to focus it; it's already focused.

Using useEffect(), useRef() and useState(), and a couple other concepts which we've learnt thus far in this course, this program can be brought to life with under 100 lines of code.

Here is the code:

function InputForm({initialValue}) {
   const [value, setValue] = useState(initialValue);
   const [isEditing, setIsEditing] = useState(false);
   const inputElement = useRef();

   useEffect(() => {
      if (isEditing) {
         // Focus the <input> element when we are in 'editing' mode.
         inputElement.current.focus();
      }
   }, [isEditing]);

   function onEdit() {
      setIsEditing(true);
   }

   function onSave() {
      setIsEditing(false);
   }

   return (
      <>
         <input type="text" value={value}
            onChange={e => setValue(e.target.value)}
            disabled={!isEditing}
            ref={inputElement} />

         {isEditing ? (
            <button onClick={onSave}>Save</button>
         ) : (
            <button onClick={onEdit}>Edit</button>
         )}
      </>
   );
}

function App() {
   return (
      <InputForm initialValue="React" />
   );
}

Live Example

Carefully follow through the program — it's really simple to understand.

Understanding the code above

The InputForm component represents the whole 'input container' that we were talking about above. It gets an initial value to render in the <input> element, in the form of the initialValue prop.

initialValue is used to initialize the value state. This value state, as is customary in React programs, is used to set the value attribute of the <input> element. The onChange handler of the element updates this value via setValue().

The other isEditing state is used to indicate the mode of InputForm. If it is true, it means that we are in 'editing' mode and thus the input must not be disabled; otherwise, we are in 'disabled' mode and so, the input must be disabled.

As can be seen in the code, the value of isEditing is used to conditionally set the disabled attribute on the <input> element, as well as to conditionally render the appropriate kind of <button>.

The Edit button changes isEditing to true with the help of the onEdit() handler function. The Save button, on the other hand, changes isEditing to false with the help of the onSave() handler. We've explicitly created named function handlers to help better illustrate the role of each button — you could also create the handlers as inline arrow functions.

Whenever isEditing changes, useEffect()'s callback gets executed. If isEditing turns out to be true at this point, the <input> element is focused, with the help of the DOM focus() method and the inputElement ref.

And that's it.

By virtue of the [isEditing] dependency array passed to useEffect(), its callback only executes when isEditing changes.

  • If we don't provide a dependency array at all, each time InputForm gets re-rendered, our callback would execute. This would mean that as we type into the input field, the callback would execute again and again, which is pointless.
  • If we provide an empty dependency array, our callback would execute only once and that on the very first render of InputForm. This, also, would be pointless.

Surely, the dependency array is amazing!

This program demonstrates the true potential of React even when one just knows how to work with the useState(), useRef() and useEffect() hooks; let alone the case when one is experienced with all of them!

Moving on, the dependency array doesn't just have to contain one dependency. We can have as many of them as we want to.

In this case, the callback would get executed whenever either of the given dependencies changes. In other words, it's not required for all of the dependencies to change in order to necessitate an execution of useEffect()'s callback.

The cleanup function

Let's now talk about the cleanup function.

The callback function provided to useEffect() can optionally return back a function that gets executed when the underlying component instance is about to be deleted. This function is referred to as the cleanup function.

The cleanup function is called whenever:

  • The underlying component instance is thrown away by React.
  • The component instance is re-rendered and the effect is put into execution.

The latter is simple to understand: if the effect doesn't get executed on a re-render, then the cleanup function won't get called.

The cleanup function is also of particular significance as is the dependency array when working with useEffect(). It's what makes sure that we clean up the memory consumed as a result of any code executed as part of the underlying side effect.

Some typical scenarios where useEffect()'s cleanup function is used include when we want to clear up JavaScript timers, e.g. setInterval(), and when we want to remove a registered event handler.

Let's take an example.

In the following code, we have a RunningCounter component alongside a normal button that toggles the rendering of RunningCounter, i.e. if the component is already rendered, it's removed; otherwise, it's rendered:

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

   useEffect(() => {
      const timerId = setInterval(() => {
         console.log('Incremented');
         setCount(count => count + 1);
      }, 1000);
   }, []);

   return (
      <h1>{count}</h1>
   );
}

function App() {
   const [counterRendered, setCounterRendered] = useState(true);
   return (
      <>
         {counterRendered && <RunningCounter/>}
         <button onClick={() => setCounterRendered(!counterRendered)}>Toggle render</button>
      </>
   );
}

Notice how RunningCounter makes a log in setInterval().

Live Example

As <RunningCounter> gets rendered with the initial page load, we witness a log with each passing second. However, when we press the toggle button, and as a result get the <RunningCounter> element thrown away, the console logs persist.

This is undesirable behavior.

Worse yet, when we press the toggle button again in order to re-render <RunningCounter>, we get a new timer set up in addition to the old one. This keeps on happening as we keep on toggling the rendering of <RunningCounter>.

When RunningCounter is gone, so should the timer tied to it, right?

But how to do so? Well, this is the job of the cleanup function of useEffect().

The idea is simple: when RunningCounter is thrown away, the ongoing interval must be cleared up as well, using JavaScript's clearInterval() function.

The following code accomplishes this idea:

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

   useEffect(() => {
      setInterval(() => {
         console.log('Incremented');
         setCount(count => count + 1);
      }, 1000);

      // The cleanup function to clear the ongoing interval.
return () => {
clearInterval(timerId);
} }, []); return ( <h1>{count}</h1> ); } function App() { const [counterRendered, setCounterRendered] = useState(true); return ( <> {counterRendered && <RunningCounter/>} <button onClick={() => setCounterRendered(!counterRendered)}>Toggle render</button> </> ); }

Live Example

Now, when we press the toggle button, as before, the <RunningCounter> element gets thrown away, and so does the timer associated with it. Thus, we don't see any logs as the element is gone.

Perfect!

"I created Codeguage to save you from falling into the same learning conundrums that I fell into."

— Bilal Adnan, Founder of Codeguage