3

My React app uses setTimeout() and setInterval(). Inside them, I need to access the state value. As we know, closures are bound to their context once created, so using state values in setTimeout() / setInterval() won't use the newest value.

Let's keep things simple and say my component is defined as such:

import { useState, useEffect, useRef } from 'react';

const Foo = () => {
    const [number, setNumber] = useState(0);
    const numberRef = useRef(number);

    // Is this common? Any pitfalls? Can it be done better?
    numberRef.current = number;

    useEffect(
        () => setInterval(
            () => {
                if (numberRef.current % 2 === 0) {
                    console.log('Yay!');
                }
            },
            1000
        ),
        []
    );

    return (
        <>
            <button type="button" onClick={() => setNumber(n => n + 1)}>
                Add one
            </button>
            <div>Number: {number}</div>
        </>
    );
};

In total I came up with 3 ideas how to achieve this, is any of them a recognized pattern?

  1. Assigning state value to ref on every render, just like above:

    numberRef.current = number;
    

    The benefit is very simplistic code.

  2. Using useEffect() to register changes of number:

    useEffect(
        () => numberRef.current = number,
        [number]
    );
    

    This one looks more React-ish, but is it really necessary? Doesn't it actually downgrade the performance when a simple assignment from point #1 could be used?

  3. Using custom setter:

    const [number, setNumberState] = useState(0);
    const numberRef = useRef(number);
    
    const setNumber = value => {
        setNumberState(value);
        numberRef.current = value;
    };
    

Is having the same value in the state and the ref a common pattern with React? And is any of these 3 ways more popular than others for any reason? What are the alternatives?

Robo Robok
  • 21,132
  • 17
  • 68
  • 126
  • 1
    Yes its a common practice, you can do this code better by clearing the interval on unmount. – Dennis Vash Oct 07 '21 at 15:23
  • 1
    If you're keeping the value in state then you don't need it in a ref. The main difference between state and ref in this case is that changing state will cause the component to rerender but changing the ref won't. It could happen that they have the same values, but since setNumber always sets state, the component will always rerender, making the ref useless. – Dov Rine Oct 10 '21 at 11:10
  • Also, be careful in the setTimeout closure since ref.current is by reference, not by value. You are not getting a snapshot of the value, you are getting the memory reference. – Dov Rine Oct 10 '21 at 11:12
  • @DovRine both of your comments are wrong. If you need to rerender, then it needs to be state. But you can't read the current state value in the closure. And also, ref.current is not a reference for primitives. It's the ref itself that is being always a reference, which is why it works in closures. – Robo Robok Oct 10 '21 at 12:14
  • @RoboRobok: Thank you for the correction. So are you saying that ref.current in the setTimeout closure will be frozen at the value it had when it was closed over? – Dov Rine Oct 11 '21 at 01:19
  • @RoboRobok: I used OPs example to demonstrate what I thought was the issue here: https://codesandbox.io/s/reverent-snow-3rkhg?file=/src/App.js . In this example, ref.current is not safe from modification in the closure, it is available by reference. Where did I misunderstand this? – Dov Rine Oct 11 '21 at 01:40
  • What do you mean by safe? – Robo Robok Oct 11 '21 at 06:09
  • @DovRine ref values don't need to be "safe". They are just helping values, unrelated to rendering. Think of `setTimeout()` id, for example. I feel like `useRef()` is misunderstood in React. – Robo Robok Oct 11 '21 at 09:48
  • @RoboRobok: I meant "safe" as in the value in the closure will not change in the setInterval callback, similarly to the reason for the existence of the let keyword. – Dov Rine Oct 11 '21 at 12:30
  • @RoboRobok: useRef is used to get a reference to a dom node or to keep track of a value that should not cause a rerender on update. AFAIK, those are its only 2 uses. Maybe the setInterval callback should be wrapped in useCallback with [number] as the dependency array. I haven't tried it yet, but I think that would work. – Dov Rine Oct 11 '21 at 12:34
  • It wouldn't work, because `setInterval()` won't automagically change the callback it's using. React's hook architecture is not perfect and situations like this prove it. There's sometimes a lot of hassle to do basic stuff. There are some packages providing hooks for `setTimeout()` and `setInterval()`, but they're not perfect neither. – Robo Robok Oct 11 '21 at 12:37
  • 4
    "Common practice"? Answers would have to submit polls of how many React applications to qualify for "common" or "popular"? I think you'd be better off asking for the pitfalls or benefits of practices rather than asking for whether they are named or common, unless that is your only criteria, in which case back to my original question. – Heretic Monkey Oct 11 '21 at 19:12
  • 1
    @HereticMonkey various frameworks have their own philosophies and popular approaches, I don't see anything wrong in asking about them. And I did ask about the traps. – Robo Robok Oct 14 '21 at 08:59
  • No. It is not common. – morganney Oct 16 '21 at 02:03
  • @morganney then how do you deal with reading state in `setTimeout` and `setInterval`? – Robo Robok Oct 16 '21 at 07:14

2 Answers2

0

2021-10-17 EDIT:

Since this looks like a common scenario I wanted to wrap this whole logic into an intuitive

useInterval(
  () => console.log(`latest number value is: ${number}`), 
  1000
)

where useInterval parameter can always "access" latest state.

After playing around for a bit in a CodeSandbox I've come to the realization that there is no way someone else hasn't already thought about a solution for this.

Lo and behold, the man himself, Dan Abramov has a blog post with a precise solution for our question https://overreacted.io/making-setinterval-declarative-with-react-hooks/

I highly recommend reading the full blog since it describes a general issue with the mismatch between declarative React programming and imperative APIs. Dan also explains his process (step by step) of developing a full solution with an ability to change interval delay when needed.

Here (CodeSandbox) you can test it in your particular case.


ORIGINAL answer:

1.

numberRef.current = number;

I would avoid this since we generally want to do state/ref updates in the useEffect instead of the render method.

In this particular case, it doesn't have much impact, however, if you were to add another state and modify it -> a render cycle would be triggered -> this code would also run and assign a value for no reason (number value wouldn't change).

2.

useEffect(
    () => numberRef.current = number,
    [number]
);

IMHO, this is the best way out of all the 3 ways you provided. This is a clean/declarative way of "syncing" managed state to the mutable ref object.

3.

const [number, setNumberState] = useState(0);
const numberRef = useRef(number);

const setNumber = value => {
    setNumberState(value);
    numberRef.current = value;
};

In my opinion, this is not ideal. Other developers are used to React API and might not see your custom setter and instead use a default setNumberState when adding more logic expecting it to be used as a "source of truth" -> setInterval will not get the latest data.

-1

You have simply forgotten to clear interval. You have to clear the interval on rendering.

  useEffect(() => {
    const id = setInterval(() => {
      if (numberRef.current % 2 === 0) {
        console.log("Yay!");
      }
    }, 1000);
    return () => clearInterval(id);
  }, []);

If you won't clear, this will keep creating a new setInterval with every click. That can lead to unwanted behaviour.

Simplified code:

const Foo = () => {
  const [number, setNumber] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      if (number % 2 === 0) {
        console.log("Yay!");
      }
    }, 1000);
    return () => clearInterval(id);
  }, [number]);

  return (
    <div>
      <button type="button" onClick={() => setNumber(number + 1)}>
        Add one
      </button>
      <div>Number: {number}</div>
    </div>
  );
};
xdeepakv
  • 7,835
  • 2
  • 22
  • 32
  • 1
    I haven't forgotten about it - I skipped it for simplicity. But not sure why you post an answer irrelevant to the question. – Robo Robok Oct 16 '21 at 17:09
  • It is not simple, It is buggy code. Whenever you create an event listener, you should clear the listeners.. – xdeepakv Oct 16 '21 at 17:12