简体   繁体   中英

Reducer function passed to useReducer hook is executed multiple times for one dispatch call when reducer function is dependant on a component prop

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:

But the questions still stand.

  1. What of those behaviours ( react@16.8.0-alpha.0 or react@16.8.0 ) of useReducer hook from the buggy example is considered correct in nowadays React?
  2. Is it a bug?
  3. 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!

useReducer needs to remember reducer to be able to say whether a component needs a rerender (needs it to compute state and compare it with the previous one). But the reducer it has access to might be stale since you can "swap" the reducer. So, in the case when the reducer state is the same as the previous one, instead of throwing the result away and calling it a day React stashes the result as well as the reducer that was used to calculate it till the next re-render and then checks if the reducer is still the same. If not, the state is calculated again using the fresh reducer.

Your example is an edge case. It shouldn't work like that but Ract doesn't know when exactly it should throw away the stale reducer state updates - it always waits till the next rerender to compare reducers and compute the final state that will be used in re-render.

Here's a simplified example that depicts it: Initially step is 0 . dispatch is run 3 times. The result is 0 + 0 + 0 = 0 and that's correctly displayed. Then, when you click on the button, step changes to 1 but dispatch isn't triggered even once. Nevertheless, the result is now 3 because all previous actions were re-run with a newly created reducer.

 function App() { console.log("rendering"); const [step, setStep] = React.useState(0); 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; } } React.useEffect(() => { for(let i = 0; i < 3; i++) { console.log("Dispatch"); dispatch({ type: "tick" }); } }, []); return ( <div> <span>{count}&nbsp;&nbsp;</span> <button onClick={(e) => setStep(step + 1)} >step +1</button> </div> ); } const rootElement = document.getElementById("root"); ReactDOM.render(<App />, rootElement);
 <script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.0/umd/react.development.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.0/umd/react-dom.development.js"></script> <div id="root"></div>

The solution is to make the reducer pure (as it should be from the beginning) and pass all the required data in action.payload ( ref ing things if needed).

 function reducer(state, action) { if (action.type === "tick") { const { step } = action.payload; console.log(`Reducer: state=${state} and step=${step}`); return state + step; } } function Counter({ step }) { const [count, dispatch] = React.useReducer(reducer, 0); const stepRef = React.useRef(); stepRef.current = step; React.useEffect(() => { console.log("Create interval"); const id = setInterval(() => { console.log("Dispatch"); dispatch({ type: "tick", payload: { step: stepRef.current }}); }, 1000); return () => { console.log("Clear interval"); clearInterval(id); }; }, [dispatch]); return <span>{count}&nbsp;&nbsp;</span>; } function App() { const [step, setStep] = React.useState(0); return ( <div> <Counter step={step} /> <input type="number" value={step} onChange={(e) => setStep(Number(e.target.value))} /> </div> ); } const rootElement = document.getElementById("root"); ReactDOM.render(<App />, rootElement);
 <script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.0/umd/react.development.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.0/umd/react-dom.development.js"></script> <div id="root"></div>

import React from "react";
import ReactDOM from "react-dom";

function App() {
  const [step, setStep] = React.useState(0);

  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 step === 0 ? state : 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>;
  }

  return (
    <>
      <Counter step={step} />
      <input
        type="number"
        value={step}
        onChange={(e) => setStep(Number(e.target.value))}
      />
    </>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM