Enea Xharja

Software Engineer

React Hooks: useEffect

React is a library for building user interfaces. One can summarize the entire purpose of React with the formula: ui = function(state). The function that computes the UI based on the state should be a pure function. That is, when React renders a component, it needs to be able to get a description of the UI without encountering side effects.

A component has a side effect whenever it does something other than take some input (props and state), and calculate some output (UI). Common side effects include: making an API call to fetch data from a server, interacting with the DOM, or using browser APIs like localStorage.

Rules for managing React effects

To ensure that the entire process of rendering a component is as fast as possible, React establishes rules on how to properly interact with side effects.

Rule 1

The foundation of React is that a component should be a pure function:

A component should render without running into side effects

When building complex applications, it is inevitable to have to interact with side effects. To solve this problem, React offers a way to escape the pure function paradigm and correctly interact with side effects.

Rule 2

A common use case for a side effect is the interaction with browser APIs, and if you want to satisfy the first rule, you need to keep that side effect out of the rendering flow.

If a side effect is triggered by an event, place it in an event handler

Suppose you want to build an application that displays a greeting to the user. The user can click a button to change the greeting. The application needs to remember the user's preferred greeting and display it when the user returns to the application, even after the browser is closed. To do this, the localStorage API can be very useful.

import * as React from "react";

export default function App() {
  const [index, setIndex] = React.useState(0);

  const greetings = ["Hello", "Ciao", "Hola", "Bonjour", "Hallo"];

  const handleClick = () => {
    const nextIndex = index === greetings.length - 1 ? 0 : index + 1;
    setIndex(nextIndex);
    localStorage.setItem("index", nextIndex);
  };

  return <button onClick={handleClick}>{greetings[index]}</button>;
}

Note that the localStorage API is used within the handleClick event handler function, considering that it is a side effect and is triggered by the onClick event.

Rule 3

Now that you can add a value in local storage, you need to retrieve it when you set the initial index value of the application.

If a side effect is synchronizing a component with some outside system, place it inside useEffect

In the example illustrated in the second rule, you want to synchronize the component with the localStorage API. To do this, you need to use the useEffect hook. It allows you to remove the side effect from the React rendering flow and delay its execution until after the component is rendered.

import * as React from "react";

export default function App() {
  const [index, setIndex] = React.useState(0);

  const greetings = ["Hello", "Ciao", "Hola", "Bonjour", "Hallo"];

  const handleClick = () => {
    const nextIndex = index === greetings.length - 1 ? 0 : index + 1;
    setIndex(nextIndex);
    localStorage.setItem("index", nextIndex);
  };

  React.useEffect(() => {
    const item = localStorage.getItem("index");

    if (item) {
      setIndex(Number(item));
    }

    console.log("effect render");
  }, []);

  console.log("initial render");

  return <button onClick={handleClick}>{greetings[index]}</button>;
}

The React useEffect hook accepts two arguments: a function and a dependency array. The function is the side effect you want to execute and is invoked after React renders. The dependency array is a list of values on which the side effect depends. The second argument is optional, but if you omit it, the side effect will be executed by default after every re-render.

Note that props, state, and any variable defined within a component are all considered reactive values that are used in the synchronization process and, therefore, must always be included in the dependency array if they are used in the effect.

The purpose of useEffect is to synchronize a component with an outside system. React should resync whenever a dependency needed by the effect changes.

Advanced use cases

The most common side effect you will encounter when building products for the web is network requests.

Fetching data from an API

Fetching data from an API represents the perfect use case for useEffect. In this case, you want to synchronize the component with the result of the API call.

Let's build a simple application that fetches data from the PokeAPI and displays some data about Pokemon. First, you need to create the function that calls the API:

export async function fetchPokemon(name) {
  try {
    const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`);

    if (response.ok) {
      return {
        error: null,
        response: await response.json(),
      };
    }

    throw new Error(`There was an error fetching the Pokemon: ${name}`);
  } catch (error) {
    return {
      error,
      response: null,
    };
  }
}

The fetchPokemon function accepts a name argument and returns an object with two properties: error and response. The error property is null if the API call was successful, otherwise it contains the error object. The response property is null if the API call failed, otherwise it contains the response object.

Now, you can use the React useEffect hook to synchronize the state of the component with the result of the call to the fetchPokemon API:

import * as React from "react";

import { fetchPokemon } from "./api";

export default function App() {
  const [name, setName] = React.useState("pikachu");
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    const handleFetchPokemon = async () => {
      setLoading(true);
      setError(null);

      const { error, response } = await fetchPokemon(name);

      if (error) {
        setError(error.message);
      } else {
        setData(response);
      }

      setLoading(false);
    };

    handleFetchPokemon();
  }, [name]);

  return <main>{JSON.stringify({ name, error, loading, data }, null, 2)}</main>;
}

The first argument of the useEffect hook is a function representing the side effect you want to execute. In this case, the side effect is an asynchronous function that calls the fetchPokemon API and updates the state of the component based on the result of the call. The second argument is the dependency array, which contains the name variable. This means that the side effect will be executed whenever the name variable changes.

useEffect cleanup function

Sometimes, it may be necessary to set event listeners or timers in a component. In this case, you need to make sure that you clean them up when the component is removed from the DOM. Otherwise, you will have a memory leak.

If you return a function from useEffect, React will call it each time before calling the effect and then one last time when the component is removed from the DOM.

Here is an example of an application that uses the visibilitychange event of the document object to detect when the page is no longer visible.

import * as React from "react";

export default function App() {
  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    const handleVisibilityChange = () => {
      if (document.visibilityState !== "visible") {
        setCount((c) => c + 1);
      }
    };

    console.log("addEventListener");
    document.addEventListener("visibilitychange", handleVisibilityChange);

    return () => {
      console.log("removeEventListener");
      document.removeEventListener("visibilitychange", handleVisibilityChange);
    };
  }, []);

  return (
    <p>
      How many times have you tabbed away? <strong>{count}</strong>
    </p>
  );
}

Whenever you move away from the page, the visibilitychange event is triggered and the count state is incremented. To avoid memory leaks, you must remove the event listener when the component is removed from the DOM. To do this, you return a function from useEffect that removes the event listener.

Notice how in the dependency array you have to pass an empty array. This is because you only want to add the event listener once. If you were to pass count as a dependency, the existing event listener would be removed and a new one would be added each time the count state changed, as React would render the component again and re-invoke the effect.

There remains the problem of how to increment the count state when the document is no longer visible. To do this, you can use the updater function setCount, which receives the previous state as an argument setCount((c) => c + 1). In this way, you can increment the count state without having to include it in the dependency array.

Remember that the purpose of useEffect is to synchronize a component with an outside system, in this case the document object. The count state is not part of the outside system, so it does not need to be included in the dependency array, since it is not necessary for React to re-execute the effect when it changes.

Another example for the cleanup function is to set a timer that updates the state of the component every second. Here is an example of an application that uses the setInterval function to display the current time:

import * as React from "react";

export default function App() {
  const [time, setTime] = React.useState(null);

  React.useEffect(() => {
    const timerId = window.setInterval(() => {
      setTime(new Date());
    }, 1000);

    return () => window.clearInterval(timerId);
  }, []);

  if (time === null) return null;

  return <p>Current Time: {time.toLocaleTimeString()}</p>;
}

The setInterval function is used to create a timer that calls the setTime function with the current time every second. The useEffect hook returns a cleanup function that clears the timer using the clearInterval function when the component is removed from the DOM.