320H.9 - React Hooks: useEffect

Learning Objectives

By the end of this lesson, learners will be able to:

  • Describe the purpose of the useEffect hook.
  • Use the useEffect hook to synchronize with external systems.
  • Describe the effect lifecycle.
  • Build setup and cleanup functions for effects.

 CodeSandbox

This lesson uses CodeSandbox as one of its tools.

If you are unfamiliar with CodeSandbox, or need a refresher, please visit our reference page on CodeSandbox for instructions on:

  • Creating an Account
  • Making a Sandbox
  • Navigating your Sandbox
  • Submitting a link to your Sandbox to Canvas

Effects

The useEffect hook, like useRef, allows us to integrate with external systems, making it an "escape hatch." While useRef stores references to external values and objects, useEffect gives us the ability to synchronize with external systems.

Examples of this are controlling non-React components based on React state, setting up external server connections, sending analytics logs when certain components render, etc. Effects allow you to run code after rendering so that you can synchronize the rendered components with external systems.

Effects are, in many ways, like events. Event handlers take user interaction and change the application's state depending on the desired effect of those actions. Sometimes, however, there is no explicit event for the action we'd like to perform.

Likewise, we can't handle those kinds of actions within our rendering code, because any code that returns the JSX for rendering must be pure; it cannot cause side effects.

As an example of something that lives outside of both rendering code and events, consider a chat room. The chat room must connect to the chat server whenever its component is rendered, but before any user interaction occurs. Forcing the user to click "connect" after the component renders would be a poor user experience, but we also can't put that connection logic into the rendering code itself.

If we think of the rendering as an event itself, effects allow you to respond to the render event. Sending a message within the chat room would be handled by an event handler, because the user clicks a specific button to do so, but setting up a connection with the chat server is an effect because it should happen any time the component renders, no matter what caused it to do so.

When discussing effects in terms of the "trigger, render, commit" cycle, they occur at the end of a commit, after the screen updates.


The useEffect Hook

In order to get started with effects, we need to import the useEffect hook:

import { useEffect } from "react";

Like all hooks, useEffect can only be called at the top level of a component, not inside of other nested statements.

useEffect takes two arguments:

  • setup - The function with the effect's logic. When the component is added to the DOM, React runs the setup function. You can also return a cleanup function from the setup function, which is run every re-render when one of the effect's dependancies change; React runs the cleanup function with the old values, and then runs the setup function with the new values. It also runs the cleanup function when the component is removed from the DOM.
  • The optional dependancies list, which contains a list of all reactive values referenced inside of the setup function.

Here's a simple example of how this looks from the React reference on useEffect:

import { useEffect } from 'react';
import { createConnection } from './chat.js';

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [serverUrl, roomId]);
  // ...
}

In the example above, we useState to manage a specific server for our chatroom.

The ChatRoom component accepts a roomId prop that is used in the createConnection function from chat.js, an arbitrary API.

useEffect has an anonymous setup function that simply creates a connection and then calls its connect() method. The setup function returns a cleanup function that calls disconnect() any time the component re-renders or is removed from the DOM.

The dependancies given to useEffect are serverUrl and roomId, since they are each used within the setup function. If either of these dependancies change, React will rerun the setup function.

It is important to note that, for effects that have no dependancies, there are two options for the second argument to useEffect:

  • An empty list, [], which causes useEffect to run only the first time the component renders.
  • No value, which causes useEffect to run every time the component is re-rendered.

useEffect Example

Let's return to a familiar example from the lesson on useRef in order to demonstrate a practical use of useEffect:

In this example, the "external system" we are synchronizing with is the browser media API.

It is important to specify the dependancies that an effect uses, as often re-running an effect after every render is slow and inefficient, especially when working with external systems that may not always respond instantly. Often, running an effect on every render is just plain incorrect, such as replaying an animation with each re-render.

If you simply put an empty list [] as the dependancy for an effect, you tell React to only run that effect on the first render. If the effect uses a dependancy, you will receive an error that looks like this:

React Hook useEffect has a missing dependency: 'isPlaying'. Either include it or remove the dependency array.

This can help you determine what belongs in your dependancy array if you are uncertain. It is important to note that you don't "choose" your own dependancies - they are determined based on what is used within the useEffect code.

If any dependancy changes value, React will re-run the effect.

Here's a reference for these dependancy differences:

useEffect(() => {
  // This runs after every render
});

useEffect(() => {
  // This runs only on mount (when the component appears)
}, []);

useEffect(() => {
  // This runs on mount *and also* if either a or b have changed since the last render
}, [a, b]);

Warning: Infinite Loops

Be careful when working with effects and state values! Effects trigger after every render, and setting state triggers a render. This means that code like this will result in infinite loops:

const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1);
});

If you are using effects to modify internal systems like React state, you might not need an effect! Effects, like other escape hatches, should be used sparingly and with external systems.


Cleanup Functions and Development Behavior

Let's return to a working example of the ChatRoom component from earlier:

If we take a look at the console, we'll notice some unexpected behavior. First, "✅ Connecting..." is logged twice. In order to help you notice bugs quickly, React remounts every component once during development, immediately following its initial mount.

This is important, because it helps us notice something: if this component ever remounts, it opens an additional connection without closing the first one. If the user navigates between pages, unmounting and remounting the ChatRoom component many times, we could end up with many, many open connections.

By remounting the component, React verifies that navigating back and forth between pages would not break the application.

If we add the cleanup function mentioned earlier, we can see how the behavior changes. Add the following code into the ChatRoom's useEffect hook, and examine the results in the console:

return () => {
  connection.disconnect();
};

With well-implemented cleanup functions, there should be no discernable difference to the user between running the effect once or running it, cleaning it up, and running it again, ad infinitum.

In production, this remounting behavior does not occur.


The Effect Lifecycle

Since effects synchronize with external systems, it is best to think of them as external to the React component lifecycle. Components mount, update, and unmount, but effects only start and stop synchronizing. In the ChatRoom example, the useEffect setup function describes how to start synchronizing, and the returned cleanup function describes how to stop synchronizing.

If we expand the ChatRoom example to include multiple chatroom options, provided to the user by a dropdown, we can illustrate how this works and why it is important:

If you explore this example, paying attention to the console, you will see how useEffect behaves when the component mounts, unmounts, and updates.

If you think of the effect in terms of the component's lifecycle, you might consider effects "callbacks" or events that fire at specific times, like after a render or before unmounting.

Instead, it is best to think of effects from their own perspective. Focus on a single cycle of starting and stopping synchronization, regardless of where it happens in the component's lifecycle. If you do this well, your effects will be robust and effective regardless of how many times they are started or stopped.

React knows that it needs to re-synchronize its effects based on their dependancies: roomId in the case of the example above.

It is also important to note that each effect should handle its own unique synchronization. If you are synchronizing multiple things, use multiple effects! Similarly, if you're not synchronizing anything, consider a different approach.


More Cleanup Examples

Here are a few more examples of cleanup functions in practice, provided by the React team...

  1. Controlling an external widget, such as a map that has a setZoomLevel method that you want to keep in sync with a state variable within React (zoomLevel):
  2. There is no cleanup required in this case, because calling setZoomLevel twice with the same value will not do anything extra.
useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
  1. Opening a modal from the built-in <dialogue> element, which does not allow you to call showModal twice:
  2. Since we can't call showModal twice, we need to make sure to call close within the cleanup function.
useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);
  1. If the effect subscribes to events, the cleanup function should unsubscribe from them in order to prevent the event handler from executing multiple times:
useEffect(() => {
  function handleScroll(e) {
    console.log(window.scrollX, window.scrollY);
  }
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);
  1. Effects that animate something should have a cleanup function that resets the animation to its initial values:
useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // Trigger the animation
  return () => {
    node.style.opacity = 0; // Reset to the initial value
  };
}, []);
  1. Fetching external data should include a cleanup function that either aborts the fetch or ignores its result:
  2. Since you can't "undo" an external network request, you just need to make sure that the initial fetch is no longer relevant (especially since asynchronous requests do not have an "order," so the first request could arrive last).
useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

  return () => {
    ignore = true;
  };
}, [userId]);
  1. When sending analytics, it might be tempting to think you will need a cleanup function; however, you do not:
  2. While this code will log visits twice due to the remounting behavior React implements during development, there is no user-visible difference in behavior here, so it does not require a cleanup function.
  3. The development logs will contain duplicates, but in production this will only run once, as intended.
useEffect(() => {
  logVisit(url); // Sends a POST request
}, [url]);

The useEffect Playground

The React team provides a useEffect example they call the "useEffect playground" which we have modified below. This playground can help you understand how effects work in practice; take some time to explore it.

The playground uses setTimeout within useEffect to schedule a console log containing the value of the text input, three seconds after the effect runs (on mount or when the state variable text changes, as it is listed as a dependancy). The cleanup function provided to useEffect cancels the pending timeout, so that we do not get logs for every keystroke if they are within three seconds of one another.

Examine the console as you mount and unmount the component and change the value of the text input. Make sure you understand why each thing is happening!


Practice Activity: Listening to Browser Events

Fork the sandbox below and add a useEffect. The useEffect should accomplish the following:

  • Create a handleMove function that sets the state variables position.x and position.y to the location of the mouse (event.clientX, event.clientY).
  • Add the handleMove function as a listener to the window's pointermove event, but only if canMove is true.
  • Return a cleanup function that removes the event listener.

When finished, the blue circle should follow your mouse cursor within the window, and pause at its location with a click. Another click should toggle the circle following your cursor again. Most of this has already been done for you! You just need to add the effect.


GLAB 320H.9.1 - React Movie Search will walk you through the steps to create an interactive search form that fetches and displays data from an external API.

If you need assistance during the lab activity, speak with one of your instructors.

Copyright © Per Scholas 2024