Recently I am reading article related to useInterval
, these code works fine:
import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
function Counter() {
const [count, setCount] = useState(0);
const savedCallback = useRef();
function callback() {
setCount(count + 1);
}
useEffect(() => {
savedCallback.current = () => {
setCount(count + 1);
};
});
useEffect(() => {
let id = setInterval(() => {
savedCallback.current()
}, 1000); // this line is critical
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
const rootElement = document.getElementById("root");
ReactDOM.render(<Counter />, rootElement);
However, after I change the critical line to
let id = setInterval(savedCallback.current, 1000);
the code won't work. I wonder what the difference between savedCallback.current
, and () => { savedCallback.current() }
?
Recently I am reading article related to useInterval
, these code works fine:
import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
function Counter() {
const [count, setCount] = useState(0);
const savedCallback = useRef();
function callback() {
setCount(count + 1);
}
useEffect(() => {
savedCallback.current = () => {
setCount(count + 1);
};
});
useEffect(() => {
let id = setInterval(() => {
savedCallback.current()
}, 1000); // this line is critical
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
const rootElement = document.getElementById("root");
ReactDOM.render(<Counter />, rootElement);
However, after I change the critical line to
let id = setInterval(savedCallback.current, 1000);
the code won't work. I wonder what the difference between savedCallback.current
, and () => { savedCallback.current() }
?
- Related: Why is variable value incorrect in callback function? – Nick Parsons Commented Feb 5 at 10:32
3 Answers
Reset to default 1The difference is that in the first case you've a function that calls the function stored in the ref instead of just the initial instance of the function stored in the ref. The second version never sees any updated callback value in the ref because it never accesses it again.
Version 1:
- Stores a new callback reference each render cycle
- A single anonymous interval callback function is passed to
setInterval
- The anonymous callback calls the current function value stored in the ref
useEffect(() => {
savedCallback.current = () => { // <-- (1)
setCount(count + 1);
};
});
useEffect(() => {
let id = setInterval(() => { // <-- (2)
savedCallback.current(); // <-- (3)
}, 1000);
return () => clearInterval(id);
}, []);
Version 2:
- Stores a new callback reference each render cycle
- The initial ref callback value is passed to
setInterval
... and never updated insetInterval
again
useEffect(() => {
savedCallback.current = () => { // <-- (1)
setCount(count + 1);
};
});
useEffect(() => {
let id = setInterval(
savedCallback.current, // <-- (2)
1000
);
return () => clearInterval(id);
}, []);
This is effectively identical to
useEffect(() => {
savedCallback.current = () => {
setCount(count + 1);
};
});
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
And this doesn't work because the value of count
is never re-enclosed for setCount(count + 1);
to function properly. It will always and forever try to update from the same initial state value and never increment past the value of 1
.
This could likely be resolved by using a functional state update, so then it doesn't matter if the ref callback updates.
useEffect(() => {
savedCallback.current = () => {
setCount((count) => count + 1); // <-- functional update
};
}, []); // <-- empty dependency, run effect once
useEffect(() => {
let id = setInterval(savedCallback.current, 1000);
return () => clearInterval(id);
}, []);
or
useEffect(() => {
let id = setInterval(() => {
setCount((count) => count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
The difference is in how the function is executed:
setInterval(savedCallback.current, 1000);
savedCallback.current
is assigned directly as the callback.
At the time setInterval runs, savedCallback.current
is undefined
, so nothing happens.
setInterval(() => { savedCallback.current() }, 1000);
The arrow function delays execution until the interval runs.
When the function executes, it correctly calls savedCallback.current()
, which has been updated inside useEffect.
In short, the anonymous function ensures that the latest savedCallback.current
is used at each interval.
There is a mismatch between the dependencies in the two useEffects. And a synchronisation is required here. The below post explains the same with a possible solution and an improvement as well.
Your intention is to increment the state by 1 which does not happen. Something still works is it invokes the function in the same manner. It means the given function is invoked in the same manner. We shall come back to this point below.
By the way, for easy reference, let us first enumerate the two cases below.
Case A:
savedCallback.current
case B
() => { savedCallback.current() }
Let us add a console logging statement as below and try the two cases. We can see the following log entries. Please note the logs are created even for case A, it means the function is being invoked rightly just as it is in case B. Therefore it works, though it does not meet the intent. We shall see the missing part below.
...
useEffect(() => {
savedCallback.current = () => {
console.log(`callback invoked, count : ${count}`);
setCount(count + 1);
};
});
...
Case A
// callback invoked, count : 0
// callback invoked, count : 0
// callback invoked, count : 0
// callback invoked, count : 0
// ...
Case B
// callback invoked, count : 1
// callback invoked, count : 2
// callback invoked, count : 3
// callback invoked, count : 4
// ...
Still there is a difference in the two logs. As, we can see, Case A always logs 0 for count. This is the missing point. Let us see why it has been printing 0 always ?
To answer this missing point, we need to inspect the following statement. The below statement defines a function object and assigns it to the ref object. And the whole statement is enclosed in the effect. And most notably, this effect does not depend on any specific state, on the contrary, it reacts to every render. It means the given statement will execute on every render. we shall continue below.
...
useEffect(() => {
savedCallback.current = () => {
console.log(`callback invoked, count : ${count}`);
setCount(count + 1);
};
});
...
As we know, a render is a follow up action of a state change. And a state change would happen only if there is an actual change in the value. It means if we execute setCount(1) more than one time, there will be only one render at the most. This is the reason that even the function is invoked correctly by each time out, we have already confirmed it through the logs, there is no render more than one time.
Therefore the below is the point of failure.
The statement SetCount(count+1) always evaluated to 1.
There are two possible points of failure for this.
first one) The dependency on which the ref object is updated.
second one) The dependency on which the setInterval is called.
As we have already found, the ref object is rightly set on every render. For every render, a new function object will be defined with the latest count state and the definition as such is assigned to the ref object. This part is fool proof.
However, the dependency on which the setInterval is called is restricted more than it should be. Right now, setInterval is invoked only once, which is on load of the component. There is a mismatch in this dependency with the dependency on which the ref object is updated. Ref object is set with a brand new function object on every render. However, setInterval is invoked only once with the first ref object. This is the issue. We have seen, case A shows alway 1 on the browser. It means a re-render happens only once.
By now, you should have understood the issue, on initial render, every thing is fine. The ref object has got new function object, and the same has been passed into setInterval. And, on the very first time out, the state is updated to 1. This triggers the first re-render. This re-render sets a new function object in the ref. However, setInterval is unaware of this newly created function object. It still holding the very first function object. Therefore for all subsequent time outs, this old or stale function object is invoked. And the root cause, this state function object has the old count which is 0. Therefore every invocation of setCount by this state function results the same value 1 which is ignored by React as there is no real change in the state. This is the reason we get 1 always rendered, and the logs always generated with count as 0 value.
A possible Solution
Rectify the dependency mismatch. Update the dependency of setInterval call to be matching with ref object's dependency as shown below.
useEffect(() => {
let id = setInterval(savedCallback.current, 1000);
return () => clearInterval(id);
});
An improvement
The above code is equivalent to the following code using setTimeout.
useEffect(() => {
setTimeout(savedCallback.current, 1000);
});