Frontend Dad Blog.

Using a Timer to Understand React's useEffect hook

Cover Image for Using a Timer to Understand React's useEffect hook

React's useEffect hook is a powerful, controversial part of the React architecture. However, its intended use is often misunderstood. In this article, I will walk through some of the ins and outs of the useEffect hook with the simple example of a timer component. We will see that a timer isn't necessarily so simple after all.

This post is dedicated to Kesha.

A Simple Countdown

Below is some React code that will set a simple timer counting down from 10 when the component mounts. I've included a cleanup function that simply logs to the console. If we run this code, we will see the log indicating that the effect hook has been run once, on mount. In the UI, we will see the timer countdown to, then past, zero. We will never see the cleanup logging unless we were to unmount the component.

So this is a very simple illustration of an effect hook:

  • The hook runs once, because we've included no dependencies in its dependency array.
  • The hook will ignore forthcoming re renders due to state changing.
  • The hook's cleanup function will only run when we unnmount the component.
import React, { useEffect } from "react";
export default function App() {
  const [timer, setTimer] = React.useState(10);

  useEffect(() => {
    console.log("calling effect");
    window.setInterval(() => {
      setTimer((time) => time - 1);
    }, 1000);

    return () => {
      console.log("cleaning up effect");
    };
  }, []);

  return (
    <div className="App">
      <div>Time left : {timer} </div>
    </div>
  );
}

A Stale Closure

The above example is a bit strange in that it doesn't allow us to stop the countdown when it reaches zero. It's likely that a more realistic implementation would want to allow for such behavior. So let's try adding a simple check such that we don't run the effect if the timer value is larger than zero.

  useEffect(() => {
    console.log("calling effect");
    window.setInterval(() => {
      if (timer > 0) {
        console.log(timer);
        setTimer((time) => time - 1);
      }
    }, 1000);

    return () => {
      console.log("cleaning up effect");
    };
  }, []);

Running this code will produce some wonky effects. The timer seems to flicker and reset, and our check doesn't seem to be working. Logging the timer value will reveal that it's eternally stuck at the value of 10. Why?

The simple bug shown above is a great illustration of the concepts of lexical scoping and closure in Javascript. In Javascript, when a function is created, it remembers it's lexical environment, the various values in the program that it initially had access to. Our bug here is due to the fact that the function within the effect was created when time equaled 10, and it doesn't have the ability to read the value as it updates.

In fact, we can pop open the Chrome DevTools and look at the value of timer within the closure. It will always be 10.

Chrome Devtools Screenshot

Curiously, our setTimer function DOES seem to be able to access the correct value of timer (here aliased as time). This is because React provides a callback option to setState hooks specifically to get around this problem of stale closures. The callback will always have access to the most up to date value of whatever state as its argument.

Dependencies Array to the Rescue...?

In order for us to get around this stale closure problem, we can take advantage of the dependency array provided by the useEffect function. This array will tell React to re-run the effect hook if any value in the array changes. With this strategy, the function will be recreated each time the timer value changes, capturing the correct and up to date value in the effect's lexical scope.

  useEffect(() => {
    console.log("calling effect");
    window.setInterval(() => {
      if (timer > 0) {
        console.log(timer);
        setTimer((time) => time - 1);
      }
    }, 1000);

    return () => {
      console.log("cleaning up effect");
    };
  }, [timer]);

We can see this by logging the closure's values in DevTools: Chrome Devtools Screenshot

Cleaning up and the useEffect hook

A common misunderstanding is that the useEffect cleanup function only runs on unmount. In fact, this isn't true, as explained in the official React docs

When your component is added to the DOM, React will run your setup function. After every re-render with changed dependencies, React will first run the cleanup function (if you provided it) with the old values, and then run your setup function with the new values.

We can take advantage of this behavior and assign on id to the setInterval call for later cleanup. The key here is that the cleanup function runs with a reference to the "old" values. Therefore, our id is still relevant. This code will clean up the interval after each tick, then set a new one. It's worth pointing out that since the interval only sticks around for one tick, we could just as soon use a setTimeout here as well.

  useEffect(() => {
      console.log("calling effect");
      const id = window.setInterval(() => {
      if (timer > 0) {
          console.log(timer);
          setTimer((time) => time - 1);
      }
      }, 1000);

      return () => {
      console.log("cleaning up effect");
      clearInterval(id);
      };
  }, [timer]);

Preserving a value between renders with useRef

Another strategy for preserving a value between renders is to use the useRef hook. The object returned by this call contains a current property that can be read and written and will persist between renders. The intention here is to store persistent values that don't directly affect the UI. A timer's ID is a good use case. In fact, a very similar use case is covered in the official docs

import React, { useEffect, useRef } from "react";
export default function App() {
  const [timer, setTimer] = React.useState(10);
  let intervalIDRef = useRef();

  useEffect(() => {
      intervalIDRef.current = window.setInterval(() => {
      if (timer > 0) {
          console.log(timer);
          setTimer((time) => time - 1);
      }
      }, 1000);

      return () => {
      console.log("cleaning up effect");
      clearInterval(intervalIDRef.current);
      };
  }, [timer]);

Sources

Official React Documentation