set interval not updating the variable

Viewed 654

I am trying to capture the no of clicks by the user. Which i wish to send to an api every 15 min, I am useing setinterval inside useEffect to achive this, but the problem is even though the state is changing outside but not inside setinterval. setinterval is only giving the initial default value.

here is the code -

const Agent = (props: RouteComponentProps) => {
  const [clicks, setClicks] = useState(0);

  const handleOnIdle = () => {
    console.log("user is idle");
    console.log("last active", new Date(getLastActiveTime()));
    console.log("total idle time: ", getTotalIdleTime() / 1000);
  };

  const handleOnActive = () => {
    console.log("user is active");
    console.log("time remaining", getRemainingTime());
  };

  const handleOnAction = () => {
    console.log("user did something", clicks);
    setClicks(clicks + 1);
  };

  const {
    getRemainingTime,
    getLastActiveTime,
    getTotalIdleTime,
  } = useIdleTimer({
    timeout: 10000,
    onIdle: handleOnIdle,
    onActive: handleOnActive,
    onAction: handleOnAction,
    debounce: 500,
  });

  useEffect(() => {
    let timer = setInterval(
      () => alert(`user clicked ${clicks} times`),
      1000 * 30
    );

    return () => {
      clearInterval(timer);
      setClicks(0);
    };
  }, []);

  return (
    <AgentLayout>
      <div className="dashboard-wrapper py-3">
        <Switch>
          <Redirect
            exact
            from={`${props.match.url}/`}
            to={`${props.match.url}/home`}
          />
          <Route path={`${props.match.url}/home`} component={Home} />
          <Route
            path={`${props.match.url}/lead-details`}
            component={leadDetails}
          />
          <Route
            path={`${props.match.url}/fill-details`}
            component={FillDetails}
          />
          <Route
            path={`${props.match.url}/my-desktime`}
            component={MyDesktime}
          />

          <Redirect to="/error" />
        </Switch>
      </div>
    </AgentLayout>

the alert is giving user clicked 0 times

2 Answers

The problem is that only the first version of clicks is used by the timer function, because it closes over the version as of the first time your component function is called. When you pass an empty dependency array to useEffect, the useEffect callback is only called once, the first time the component function is called (when mounting the component).

You can fix that by having the timer function use the setClicks method instead:

useEffect(() => {
    let timer = setInterval(
        () => {
            setClicks(currentClicks => {                      // ***
                alert(`user clicked ${currentClicks} times`); // ***
                return currentClicks;                         // ***
            });                                               // ***
        },
        1000 * 30
    );

    return () => {
        clearInterval(timer);
        // setClicks(0); // <=== Don't do this, the component is unmounting
    };
}, []);

Now, the timer calls the setClicks using the callback form, which means it receives the current value of clicks. Because it returns that same value, it doesn't update the state of the component.

It's also possible to solve this by adding clicks as a dependency to the useEffect, but it's a bit complicated. The naive way would be just to do this:

// The naive way
useEffect(() => {
    let timer = setInterval(
        () => {
            alert(`user clicked ${clicks} times`);
        },
        1000 * 30
    );

    return () => {
        clearInterval(timer);
        // setClicks(0); // <=== Don't do this, the user's click count would get reset every time
    };
}, [clicks]);
//  ^^^^^^−−−−−−−−−−−−− ***

That will mostly work, but it will restart the timer every time clicks changes, even if that means that instead of waiting 30 seconds, it waits 45 (because clicks changed after 15 seconds, which cancelled the previous interval and started it again). That probably wouldn't matter for a really short interval, but for a 30 second one it seems less than ideal. Doing it this way without messing up the timing of the interval requires that you remember when the next timer callback should have happened and adjusting the duration of the initial delay to match, which gets fairly complicated.


I didn't notice earlier, but any time you're updating state based on existing state, I recommend using the callback form. So your

const handleOnAction = () => {
    setClicks(clicks => clicks + 1); // Note the function callback
};

Issues

  1. You've a stale enclosure of the clicks state, closed over from the initial render cycle when the mounting effect ran and started the interval.
  2. Your click updater should use a functional update to update the clicks count from the previous state. This covers if the user is somehow able to click faster then the react component lifecycle and enqueue more than one clicks update in a render cycle.

T.J.'s answer is good, but I consider the useState updater function to be a pure function and the alert(`user clicked ${currentClicks} times`); in the middle of it is a side-effect and (IMHO) should be avoided.

Solution

Update handleOnAction to use a functional state update.

const handleOnAction = () => {
  console.log("user did something", clicks);
  setClicks(clicks => clicks + 1);
};

I suggest using a React ref and additional useEffect to update it, to hold a cached copy of the clicks state for the interval callback to reference.

const clicksRef = useRef();

useEffect(() => {
  clicksRef.current = clicks; // update clicks ref when clicks updates
}, [clicks]);

useEffect(() => {
  const timer = setInterval(
    () => alert(`user clicked ${clicksRef.current} times`), // use clicks ref instead
    1000 * 30
  );

  return () => {
    clearInterval(timer);
    // TJ already covered removing this extra state update
  };
}, []);
Related