I was reading an article called “A Complete Guide to useEffect” and tried to implement and an example from “Why useReducer Is the Cheat Mode of Hooks” section.
In that example there is a Counter component that defines state (just a number) with the help of useReducer hook. Reducer handles only one action — 'tick' on which it increments the state by the value of step prop. 'tick' action is dispatched every second in interval function that is set up in useEffect hook once.
Here is the code from that example with some minor modifications:
function Counter({ step }) {
const [count, dispatch] = React.useReducer(reducer, 0);
function reducer(state, action) {
if (action.type === "tick") {
console.log(`Reducer: state=${state} and step=${step}`);
return state + step;
} else {
throw new Error(`Unknown action type: ${action.type}`);
}
}
React.useEffect(() => {
console.log("Create interval");
const id = setInterval(() => {
console.log("Dispatch");
dispatch({ type: "tick" });
}, 1000);
return () => {
console.log("Clear interval");
clearInterval(id);
};
}, [dispatch]);
return <h1>{count}</h1>;
}
function App() {
const [step, setStep] = React.useState(0);
return (
<>
<Counter step={step} />
<input
type="number"
value={step}
onChange={(e) => setStep(Number(e.target.value))}
/>
</>
);
}
What I found is that that example works on react@16.8.0-alpha.0 and doesn't on react@16.8.0 and higher. When I run the code the initial value is 0 for both step and counter. If I wait for 3 seconds without changing anything and then increment the step I get the following output:
Create interval
Dispatch
Reducer: state=0 and step=0
Dispatch
Reducer: state=0 and step=0
Dispatch
Reducer: state=0 and step=0
Reducer: state=0 and step=1
Reducer: state=1 and step=1
Reducer: state=2 and step=1
Dispatch
Reducer: state=3 and step=1
Reducer: state=3 and step=1
Dispatch
Reducer: state=4 and step=1
Dispatch
Reducer: state=5 and step=1
As you can see by the logs the reducer is executed more than the "tick" action was dispatched.
I have managed to make it work as expected by creating a ref from step prop and memoizing the reducer with useCallback hook without any dependencies.
const stepRef = React.useRef(step);
React.useEffect(() => {
stepRef.current = step;
}, [step]);
const reducer = useCallback((state, action) => {
if (action.type === "tick") {
console.log(`Reducer: state=${state} and step=${stepRef.current}`);
return state + stepRef.current;
} else {
throw new Error(`Unknown action type: ${action.type}`);
}
}, []);
You can play with the examples here:
- Buggy one from the article (if you change React version to
react@16.8.0-alpha.0it will work as expected though); - Fixed one (works on both versions).
But the questions still stand.
- What of those behaviours (
react@16.8.0-alpha.0orreact@16.8.0) ofuseReducerhook from the buggy example is considered correct in nowadays React? - Is it a bug?
- If it's not a bug then why it works that way and reducer is triggered more than needed?
The answer to the last question should be somehow related to the the fact that reducer is being recreated. Be it on every render or even only when step prop changes it doesn't matter as memoizing the reducer with useCallback hook and passing [step] as a dependency array doesn't fix the problem. Does anyone have any ideas about that?
Thanks!