1

The following example is of a Timer component that has a button (to start the timer), and two tags that display the number of elapsed seconds, and the number of elapsed seconds times 2.

However, it does not work (CodeSandbox Demo)

The Code

import React, { useState, useEffect } from "react";

const Timer = () => {
  const [doubleSeconds, setDoubleSeconds] = useState(0);
  const [seconds, setSeconds] = useState(0);
  const [isActive, setIsActive] = useState(false);

  useEffect(() => {
    let interval = null;
    if (isActive) {
      interval = setInterval(() => {
        console.log("Creating Interval");
        setSeconds((prev) => prev + 1);
        setDoubleSeconds(seconds * 2);
      }, 1000);
    } else {
      clearInterval(interval);
    }
    return () => {
      console.log("Destroying Interval");
      clearInterval(interval);
    };
  }, [isActive]);

  return (
    <div className="app">
      <button onClick={() => setIsActive((prev) => !prev)} type="button">
        {isActive ? "Pause Timer" : "Play Timer"}
      </button>
      <h3>Seconds: {seconds}</h3>
      <h3>Seconds x2: {doubleSeconds}</h3>
    </div>
  );
};

export { Timer as default };

The Problem

Inside the useEffect call, the "seconds" value will always be equal to the its value when the useEffect block was last rendered (when isActive last changed). This will result in the setDoubleSeconds(seconds * 2) statement to fail. The React Hooks ESLint plugin gives me a warning regarding this problem that reads:

React Hook useEffect has a missing dependency: 'seconds'. Either include it or remove the dependency array. You can also replace multiple useState variables with useReducer if 'setDoubleSeconds' needs the current value of 'seconds'. (react-hooks/exhaustive-deps)eslint

And correctly so, adding "seconds" to the dependency array (and changing setDoubleSeconds(seconds * 2) to setDoubleSeconds((seconds + 1) * ) will render the correct results. However, this has a nasty side effect of causing the interval to be created and destroyed on every render (the console.log("Destroying Interval") fires on every render).

So now I am looking at the other recommendation from the ESLint warning "You can also replace multiple useState variables with useReducer if 'setDoubleSeconds' needs the current value of 'seconds'".

I do not understand this recommendation. If I create a reducer and use it like so:

import React, { useState, useEffect, useReducer } from "react";

const reducer = (state, action) => {
    switch (action.type) {
        case "SET": {
            return action.seconds;
        }
        default: {
            return state;
        }
    }
};

const Timer = () => {
    const [doubleSeconds, dispatch] = useReducer(reducer, 0);
    const [seconds, setSeconds] = useState(0);
    const [isActive, setIsActive] = useState(false);

    useEffect(() => {
        let interval = null;
        if (isActive) {
            interval = setInterval(() => {
                console.log("Creating Interval");
                setSeconds((prev) => prev + 1);
                dispatch({ type: "SET", seconds });
            }, 1000);
        } else {
            clearInterval(interval);
        }
        return () => {
            console.log("Destroying Interval");
            clearInterval(interval);
        };
    }, [isActive]);

    return (
        <div className="app">
            <button onClick={() => setIsActive((prev) => !prev)} type="button">
                {isActive ? "Pause Timer" : "Play Timer"}
            </button>
            <h3>Seconds: {seconds}</h3>
            <h3>Seconds x2: {doubleSeconds}</h3>
        </div>
    );
};

export { Timer as default };

The problem of stale values will still exist (CodeSandbox Demo (using Reducers)).

The Question(s)

So what is the recommendation for this scenario? Do I take the performance hit and simply add "seconds" to the dependency array? Do I create another useEffect block that depends on "seconds" and call "setDoubleSeconds()" in there? Do I merge "seconds" and "doubleSeconds" into a single state object? Do I use refs?

Also, you might be thinking "Why don't you simply change <h3>Seconds x2: {doubleSeconds}</h3>" to <h3>Seconds x2: {seconds * 2}</h3> and remove the 'doubleSeconds' state?". In my real application doubleSeconds is passed to a Child component and I do not want the Child component to know how seconds is mapped to doubleSeconds as it makes the Child less re-usable.

Thanks!

Espresso
  • 740
  • 13
  • 32

2 Answers2

2

You can access a value inside an effect callback without adding it as a dep in a few ways.

  1. setState. You can tap the up-to-date value of a state variable through its setter.
setSeconds(seconds => (setDoubleSeconds(seconds * 2), seconds));
  1. Ref. You can pass a ref as a dependency and it'll never change. You need to manually keep it up to date, though.
const secondsRef = useRef(0);
const [seconds, setSeconds] = useReducer((_state, action) => (secondsRef.current = action), 0);

You can then use secondsRef.current to access seconds in a block of code without having it trigger deps changes.

setDoubleSeconds(secondsRef.current * 2);

In my opinion you should never omit a dependency from the deps array. Use a hack like the above to make sure your values are up-to-date if you need the deps not to change.

Always first consider if there's some more elegant way to write your code than hacking a value into a callback. In your example doubleSeconds can be expressed as a derivative of seconds.

const [seconds, setSeconds] = useState(0);
const doubleSeconds = seconds * 2;

Sometimes applications aren't that simple so you may need to use the hacks described above.

Jemi Salo
  • 3,401
  • 3
  • 14
  • 25
0
  • Do I take the performance hit and simply add "seconds" to the dependency array?
  • Do I create another useEffect block that depends on "seconds" and call "setDoubleSeconds()" in there?
  • Do I merge "seconds" and "doubleSeconds" into a single state object?
  • Do I use refs?

All of them work correctly, although personally I would rather choose the second approach:

useEffect(() => {
    setDoubleSeconds(seconds * 2);
}, [seconds]);

However:

In my real application doubleSeconds is passed to a Child component and I do not want the Child component to know how seconds is mapped to doubleSeconds as it makes the Child less re-usable

That is questionable. Child component might be implemented like the following:

const Child = ({second}) => (
  <p>Seconds: {second}s</p>
);

And parent component should look like the following:

const [seconds, setSeconds] = useState(0);
useEffect(() => {
  // change seconds
}, []);

return (
  <React.Fragment>
    <Child seconds={second} />
    <Child seconds={second * 2} />
  </React.Fragment>
);

This would be a more clear and concise way.

glinda93
  • 7,659
  • 5
  • 40
  • 78