React Effect Hook Explained

Sandra Spanik - Feb 15 '21 - - Dev Community

I've recently learned about React's inbuilt useEffect hook and let me tell you - it's neat! ๐Ÿ’™ It's commonly used to retrieve data from external APIs and handle timers. This article will walk you through a step-by-step implementation of a timer component via the useEffect hook.

Let's say our timer component should update each second, like so:

Timer working

If you're new to React, you might be tempted to define an interval at the top level of your component, as below:

import React, { useState } from "react";
import "./TimerDemo.css";

const TimerDemo = () => {
  const [seconds, setSeconds] = useState(0);

 setInterval(() => {
    setSeconds((s) => s + 1);
  }, 1000);

  return (
    <div className="TimerDemo">
      <h1>Timer Demo</h1>
      <div>โŒ› {seconds} โŒ›</div>
    </div>
  );
};

export default TimerDemo;
Enter fullscreen mode Exit fullscreen mode

However, the above code will result in the following output.

Timer going mad

What's going on here? Has React broken the Universe and altered the rules of spacetime? ๐Ÿ‘ฝ Not quite. What's happening here is that multiple intervals are being set over and over in quick succession.

When the component renders for the first time, the interval is set, which changes the state of seconds each second. Once the state changes, a re-render of the entire component is immediately triggered, and the code inside the component runs once more. Upon running, the code will result in another, identical, interval being set. But the old interval will also continue doing its thing and manipulating the state of seconds.

So now we have two intervals that are both behaving the same way: updating state each second and triggering the component to re-render. Each re-render will lead to more fresh intervals being set, which in turn will trigger state change. This cycle - a positive feedback loop - will repeat ad infinitum (or more likely ad browser-crashium). ๐Ÿ”

The correct way to handle timers in React is by using the splendiferous useEffect hook. The top 3 things to know about this hook are:

  • it accepts a callback function as the 1st argument
  • it accepts an array as its optional 2nd argument
  • it returns null, but can optionally be customised to return a function, which is useful for "cleanup" purposes
  • it's hard to represent emojitively, because there isn't yet cross-browser support for the hook emoji, so here's an anchor instead โš“ (okay, this one was a bonus)

We'll examine how each of these points relates to handling timers in React. (Except the emoji one. Which doesn't.)

1. useEffect Callback Function

The first argument the hook accepts is a callback function. This function is what React understands to be the "effect". In this case, the effect is our interval. Let's define it inside a useEffect hook.

const TimerDemo = () => {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    setInterval(() => {
      setSeconds((s) => s + 1);
    }, 1000);
  });

  return (
    <div className="TimerDemo">
      <h1>Timer Demo</h1>
      <div>โŒ› {seconds} โŒ›</div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

This will display as the following:

Timer still going mad

Not at all what we want, our issue remains. This is where the second argument of the useEffect hook comes into play.

useEffect Dependency Array

The second argument is an optional array of state or prop values, which specify when the effect should run. We have 3 options here:

  1. No array: if the array is left out, as in the previous code snippet, the effect will run after every render.
  2. Empty array: the effect runs once after the initial render.
  3. Array with state or prop values: the effect runs only when any of these values change.

In our case, if we passed in [seconds], we would tell React to re-run the effect each time the state of seconds changes. Which would, of course, be completely pointless - in fact, this is exactly what we're trying to avoid.

And avoid it we can; specifically, by passing in an empty dependency array, []. An empty array tells React to only run the effect once, after the component renders for the first time. Let's examine the code below:

const TimerDemo = () => {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    setInterval(() => {
      setSeconds((s) => s + 1);
    }, 1000);
  }, []);

  return (
    <div className="TimerDemo">
      <h1>Timer Demo</h1>
      <div>โŒ› {seconds} โŒ›</div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Timer working

Voila! The timer works now.

useEffect Return Function

But what would happen to the interval if we removed the timer component from the DOM? Let's create a scenario where it is removed by wrapping it in another component, Clock, which toggles the timer component. Let's also include a toggle button that removes or adds the timer component to the DOM.

const Clock = () => {
  const [isHidden, setIsHidden] = useState(false);
  const toggle = () => setIsHidden((hidden) => !hidden);

  return (
    <div className="Clock">
      {!isHidden && <TimerDemo />}
      <button class="Clock-btn" onClick={toggle}>
        Toggle
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Timer seemingly working as intended

At first glance, removing the TimerDemo component from the DOM appears to work as intended. But upon opening the console, an angry sea of red appears. React is not happy. ๐Ÿ˜ก

Error message

Why is this? Well, when the component is removed, despite disappearing visually, the interval associated with it simply keeps going. There's nothing telling it to stop executing. The interval will proceed to try and update the state of a currently unmounted component, greatly upsetting React in the process. Poor React! ๐Ÿฅบ

So, how do we tell the interval to stop when the component is removed from the DOM? By specifying useEffect's return value. By default, it returns null, but we can modify this to return a callback function, which will act as "cleanup". Warning: the cleanup function can feel a bit abstract to read about, and the best way to befriend it is to use it and explore its functionality first-hand.

The cleanup is executed at the following times:

  • After the initial rendering, the hook only invokes the effect. The cleanup function doesn't run
  • On all following re-renderings, the cleanup from the previous effect execution is invoked first, after which the current effect runs
  • The cleanup also runs after the component is unmounted, i.e. removed from the DOM

Let's define our cleanup function. In order to stop an interval, we need to capture the interval ID and pass it into a clearInterval function (a vanilla JS concept). We'll return this function inside our useEffect and add some print statements to monitor the sequence of execution.

  useEffect(() => {
    console.log("I am the effect. PARTY! ๐ŸŽบ ๐Ÿ‘ ๐ŸŽ‰");
    const timerId = setInterval(() => {
      setSeconds((s) => s + 1);
    }, 1000);

    return () => {
      console.log("Cleaning up... ๐Ÿ™„ ๐Ÿงน๐Ÿงผ");
      clearInterval(timerId);
    };
  }, []);
Enter fullscreen mode Exit fullscreen mode

cleanup function demonstration

In this case, the effect runs when the component is mounted, but never again, due to our 2nd argument, the dependency array, being empty. Therefore, the cleanup function will only run when the component is unmounted, thereby clearing the interval and preventing the error message. The toggle button now works as intended, and React is so happy it can barely contain its excitement.

I hope you are too, after making it through this tutorial! ๐Ÿ‘

. . . . . . . . . . . . .