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?
Assigning state value to ref on every render, just like above:
numberRef.current = number;
The benefit is very simplistic code.
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?
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?
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.
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>
);
};