React hooks and “setInterval”

If you’ve ever come across having to use setInterval within react hooks you probably hit a wall where the component just doesn’t act as it should… For example, we might naively code something like this:

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

export default function App() {
  const [counter, changeCounter] = useState(0);

  setInterval(() => {
    changeCounter(counter + 1);
  }, 10000);

  return (
    <div className="App">
      <h1>DVAS0004 setInterval()</h1>
      <h2>Sandbox counter: {counter}</h2>
    </div>
  );
}

Simple enough; the intent behind the above code is to update our “counter” state variable every so often, and reflect that in the UI. You’d quickly end up running into weird behavior:

In fact, there’s a very good blog post by Dan Abramov himself on why this is so…

But let’s try build something a bit simpler than a setInterval hook… We might think that we should encapsulate the setInterval logic within a “useEffect” hook, and have it run once when the component is mounted:

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

export default function App() {
  const [counter, changeCounter] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      changeCounter(counter + 1);
    }, 10000);

    return () => clearInterval(interval)
  }, []);

  return (
    <div className="App">
      <h1>DVAS0004 setInterval()</h1>
      <h2>Sandbox counter: {counter}</h2>
    </div>
  );
}

Better, but we still run into erratic timings and weird state updates as can be seen below (note screen-cast is not sped up… it shows the timing interval is indeed off):

The problem is one of scope… when we declare the setInterval function within useEffect, the value of “counter” being read is the one that’s present when useEffect is run – i.e. when the component mounts. Subsequent intervals fall back to the initial “counter” value.

There are two solutions to this:

  • useEffect dependencies
  • useState functional update

Solution (sort of) 1: useEffect dependencies

Since “counter” is changed by setInterval, we need useEffect to realize a change has occurred and re-run the setInterval function, this time feeding it the new, updated value of “counter”. So we should add “counter” to our list of dependencies:

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

export default function App() {
  const [counter, changeCounter] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      changeCounter(counter + 1);
    }, 1000);

    return () => clearInterval(interval);
  }, [counter]); ///<--- this right here

  return (
    <div className="App">
      <h1>DVAS0004 setInterval()</h1>
      <h2>Sandbox counter: {counter}</h2>
    </div>
  );
}

Note we also made sure that useEffect clears the interval that we created. This is done by returning a function from useEffect, which in out case runs “clearInterval”. This is done since everytime “counter” changes, a new “setInterval” is created since “counter” is now a dependency of useEffect, which runs whenever a dependency changes.

As the react docs state (see below), the returned function from useEffect is run to “clean up an effect”, including any time there’s a new render, as is our case:

When exactly does React clean up an effect? React performs the cleanup when the component unmounts. However, as we learned earlier, effects run for every render and not just once. This is why React also cleans up effects from the previous render before running the effects next time. We’ll discuss why this helps avoid bugs and how to opt out of this behavior in case it creates performance issues later below.

https://reactjs.org/docs/hooks-effect.html

This gives us the desired effect. But what if our use case does not call for “counter” to be displayed? In this case, there wouldn’t be a re-render so we’d never call our “clearInterval”. We’d end up with a memory leak… which is why this solution is “sort of” and inferior to the next solution

Solution 2: useState functional updates

The preferred solution would be instead to use functional updates in useState. Rather than directly passing a variable/object to our useState updater (changeCounter in our case), we can pass a function, which takes as an argument the previous value of counter:

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

export default function App() {
  const [counter, changeCounter] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      changeCounter(prevCounter => prevCounter + 1);
    }, 1000);

    return () => clearInterval(interval);
  }, []);

  return (
    <div className="App">
      <h1>DVAS0004 setInterval()</h1>
      <h2>Sandbox counter: {counter}</h2>
    </div>
  );
}

As a general rule of thumb, you should always use this approach whenever a state update depends on a previous one. As shown below, we also get the desired result here, but note that now “counter” is no longer a dependency of useState, so we dont need to worry about memory leaks if we dont re-render