[React's cheating mode] Understand the advantages and advanced usage of useReducer

Posted Jun 28, 20205 min read

Perhaps you already know, "when multiple states need to be updated together, you should consider using useReducer"; perhaps you have also heard that "using useReducer can improve the performance of the application". But this article hopes to help you understand:why useReducer can improve the readability and performance of the code, and how to read the value of props in the reducer.

Due to the decoupling mode and advanced usage created by useReducer, Dan Abramov of the React team described useReducer as "React's cheating mode" .

Advantages of useReducer

As an example:

function Counter() {
  const [count, setCount]= useState(0);
  const [step, setStep]= useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step); //Rely on other state to update
    }, 1000);
    return() => clearInterval(id);
    //To ensure that the step in setCount is up to date,
    //We also need to specify the step in the deps array
  }, [step]);

  return(
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
 );
}

This code can work normally, but as the state of interdependence increases, the logic in setState will become very complicated, and the deeps array of useEffect will also become more complicated, reducing readability At the same time, the timing of re-execution of useEffect becomes more unpredictable.

After using useReducer instead of useState:

function Counter() {
  const [state, dispatch]= useReducer(reducer, initialState);
  const {count, step} = state;

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type:'tick' });
      }, 1000);
    return() => clearInterval(id);
  }, []); //deps array does not need to contain step

  return(
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
 )
}

Now the component only needs to issue an action without knowing how to update the state. That is to decouple What to dofrom How to do. The sign of complete decoupling is:useReducer always returns same dispatch function(the channel that issued the action), no matter how the reducer(state update logic) changes.

This is one of the uselessness of useReducer, which will be detailed below

On the other hand, the update of step will not cause the useEffect to be invalidated and re-executed. Because useEffect now depends on dispatch, not on state value(thanks to the decoupling mode above). This is an important mode that can be used to avoid the problem of frequent re-execution of useEffect, useMemo, and useCallback.

The following is the definition of state, where the reducer encapsulates the "how to update state" logic:

const initialState = {
  count:0,
  step:1,
};

function reducer(state, action) {
  const {count, step} = state;
  if(action.type ==='tick') {
    return {count:count + step, step };
  } else if(action.type ==='step') {
    return {count, step:action.step };
  } else {
    throw new Error();
  }
}

to sum up:

  • When the state update logic is more complicated, you should consider using useReducer. because:

    • reducer is better than setState at describing "how to update state". For example, a reducer can read related states and update multiple states at the same time.
    • [Component is responsible for issuing action, reducer is responsible for updating state]The decoupling mode makes the code logic clearer.
    • To keep it simple, whenever you write setState(prevState => newState), you should consider whether it is worth replacing it with useReducer.
  • By passing the dispatch of useReducer, the transmission of state values can be reduced.

    • useReducer always returns the same dispatch function, which is a sign of complete decoupling:the state update logic can be changed arbitrarily, and the channel for initiating actions is always the same
    • Thanks to the previous decoupling mode, useEffect function body and callback function only need to use dispatch to issue actions, without directly relying on state value. Therefore, there is no need to include the status value in the deps arrays of useEffect, useCallback, and useMemo, which also reduces the need to update them. Not only can improve readability, but also can improve performance(useCallback, useMemo update often leads to the refresh of sub-components).

Advanced usage:inline reducer

You can declare the reducer inside the component, so that you can access the props and the previous hooks result through the closure:

function Counter({ step }) {
  const [count, dispatch]= useReducer(reducer, 0);
  function reducer(state, action) {
    if(action.type ==='tick') {
      //Any variable inside the component can be accessed through the closure
      //Including props and the results of hooks before useReducer
      return state + step;
    } else {
      throw new Error();
    }
  }

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type:'tick' });
    }, 1000);
    return() => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

This ability may surprise many people. Because most people's understanding of the trigger timing of the reducer is wrong(including the previous me). The trigger timing I understood before was this:

  1. A button is clicked by the user, its onClick is called, which executes dispatch({type:'add'}), and the React framework schedules an update
  2. The React framework processes the update just scheduled and calls reducer(prevState, {type:'add'}) to get the new state
  3. The React framework uses the new state to render the component tree, and when it is rendered to the Counter component's useReducer, return to the new state obtained in the previous step.

But in fact, React will in the next rendering call the reducer to process the action:

  1. A button is clicked by the user, its onClick is called, which executes dispatch({type:'add'}), and the React framework schedules an update
  2. The React framework processes the update just scheduled and begins to re-render the component tree
  3. When rendering to the useReducer of the Counter component, call reducer(prevState, {type:'add'}) to process the previous action

The important difference is that the called reducer is the reducer function of this render, and its closure captures the props of this render.

According to the above misunderstanding, the called reducer is the reducer function of the last render, and its closure captures the props of the last render(because this rendering has not started yet)

In fact, if you simply use console.log to print the execution order, you will find that the reducer is executed synchronously when the new render executes useReducer:

  console.log("before useReducer");
  const [state, dispatch]= useReducer(reducer, initialState);
  console.log("after useReducer", state);

  function reducer(prevState, action) {
    //these current state var are not initialized yet
    //would trigger error if not transpiled to es5 var
    console.log("reducer run", state, count, step);
    return prevState;
  }

After calling dispatch, it will output:

before useReducer
reducer undefined undefined undefined
after useReducer {count:1, step:1}

Prove that the reducer is indeed called synchronously by useReducer to get the new state.
codesandbox demo

References