useState vs useReducer

We can use the useState to internally maintain state of a react component but as soon the internal state gets complex the number of useStatecan get overwhelming. For this we can use the useReducer.

useReducer

For the sake of learning middleware to handle async operations the example has been kept simple. The following example is taken from the official react documentation. It has two buttons one is used to increment and other is used to decrement. The application uses useReducer to maintain its state.

const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

Lets suppose that on the button click we want to call an api, and increment it by the value from returned the api. I know its a superficial example but the point is to demonstrate how to handle async in useReducer

As we know that reducers are pure function and can not have any side effect so we can not perform the api call inside the useReducer. To avoid this we can use thunks

Thunk

What is a thunk? thunk is function which is returned by another function. Its more easier to explain it via code:

function notAThunk() {
  return function aThunk() {
  }
}

Thunk is a piece of code encapsulated inside a function which can be called at a later time.

So instead of passing an object to the dispatch function we will pass a function. This passed function is thunk, and will be executed at a later time by the middleware. lets modify our code so it can handle the thunk via middleware. Inside Counter we will replace:

  const [state, dispatch] = useReducer(reducer, initialState);

with the following code:

  const [state, orignalDispatch] = useReducer(reducer, initialState);

  const dispatch = (action) => {
    if (isFunction(action)) {
      action(orignalDispatch);
    } else {
      orignalDispatch(action);
    }
  };

We have renamed the dispatch returned by useReducer to orignalDispatch. In essence we have created a middleware. As the following code is executed whenever we dispatach an action and before the action is received by the reducer

if (isFunction(action)) {
    action(orignalDispatch);
  } else {
    orignalDispatch(action);
  }

We have created a new function called dispatch. Our implementation first checks if the passed action is a type of function if it is a function then it is called with orignalDispatch as a arugment, otherwise we simply pass the action to orignalDispatch.

Now lets simulate a fake api call:

const asynIncrementApi = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(Math.floor(Math.random() * 10) + 1);
    }, 1000);
  });
};

asynIncrementApi returns a promise with a value between 1 to 10 after atleast 1000ms have passed.

With api in place, Lets add a new button Async Increment to the Counter.

Count: {state.count}
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button>Async Increment</button>

Lets hook up Async Increment with event handler.

<button
  onClick={() => {
    dispatch(() => {
      asynIncrementApi().then((value) => {
        console.log(value);
        dispatch({
          type: "increment_async",
          count: value,
        });
      });
    });
  }}
>
  Async Increment
</button>;

When user clicks on the Async Increment button, its event handler is executed which makes a call to dispatch function. The action to dispatch function is our thunk.

In the above example following function is the thunk

() => {
  asynIncrementApi().then((value) => {
    dispatch({
      type: "increment_async",
      count: value,
    });
  });
};

Inside the dispatch function our check isFunction returns true and executes the thunk. In this case our thunk ignores the parameters passed to it. It is because the dispatch is in our scope but there might be some case where the dispatch might not be in scope (like if code is outside the component or in a separate file) so its better to pass it.

Inside our thunk the backend api is called and as soon the promise returned by the api is resolved we dispatch a new action with type of increment_async and count with value returned from the backend.

This is all which is required to handle the asyc actions. Now lets update our reducer to handle the increment_async.

  case "increment_async":
      return { count: state.count + action.count };

Below is all the code and here is link to the codesandbox

import "./styles.css";
import { useReducer } from "react";

function isFunction(functionToCheck) {
  return (
    functionToCheck && {}.toString.call(functionToCheck) === "[object Function]"
  );
}

const initialState = { count: 0 };

function reducer(state, action) {
  console.log(action.type);
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "increment_async":
      return { count: state.count + action.count };
    case "decrement":
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}

const asynIncrementApi = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(Math.floor(Math.random() * 10) + 1);
    }, 1000);
  });
};

function Counter() {
  const [state, orignalDispatch] = useReducer(reducer, initialState);

  const dispatch = (action) => {
    if (isFunction(action)) {
      action(orignalDispatch);
    } else {
      orignalDispatch(action);
    }
  };

  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button
        onClick={() => {
          dispatch(() => {
            asynIncrementApi().then((value) => {
              dispatch({
                type: "increment_async",
                count: value
              });
            });
          });
        }}
      >
        Async Increment
      </button>
    </>
  );
}

export default function App() {
  return (
    <div className="App">
      <Counter />
    </div>
  );
}

Refactor the middleware into a separate hook

Lets create a new hook which encapsulates the logic of creating reducer and middleware into a separate hook.

const useThunkReducer = (reducer, initialState) => {
  const [state, dispatch] = useReducer(reducer, initialState);

  const enhancedDispatch = useCallback(
    action => {
      if (typeof action === 'function') {
        action(dispatch);
      } else {
        dispatch(action);
      }
    },
    [dispatch],
  );

  return [state, enhancedDispatch];
};

Now we use useThunkReducer instead of useReducer. This example was inspired from