A simple asynchronous alternative to React's useReducer

Even though React's useReducer has gained a lot of popularity during the last couple of years, it can be difficult to use for some common cases. Specifically, it requires a lot of boilerplate to support async actions.

Sure, there are multiple ways of performing side effects/ async actions with useReducer such as using a useEffect or maybe making use of other libraries that extend the useReducer hook, either by depending on thunks or async action handlers to support such functionality.

But there is always a simpler and better way. useSimpleReducer offers an approach that is more intuitive and less verbose making it easier to create asynchronous actions.

Use it today by installing it from its NPM package.

npm i @bitovi/use-simple-reducer

Or try a working demo here.

The problems use-simple-reducer solves

There are multiple benefits of using useSimpleReducer over useReducer:

  • Easy to create async actions
  • Less boilerplate code
  • Error handling and recovery
  • Built-in type checking

Easy to create asynchronous actions

One of the most common patterns in front-end development is to:

  • Asynchronously update the server upon some user action (ex: clicking a button)
  • Show that the server is being updated (ex: a spinner or a disabled action button)
  • Show the updated state when the action completes.
  • Return an error if the async action fails

A simple case is a counter.  You want your JSX to look like this:

 

<div>
          <button onClick={() => add(2)}>Add</button>
          <div>
            <p>Steps: {count}</p>
            <div>{isActive ? <Loader /> : "Processing completed"}</div>
            {error && <p>Error: {error}</p>}
          </div>
        </div>

Where:

  • add async updates the server
  • isActive displays a spinner while the action is running
  • count will be updated when the state changes
  • error will be of a non null value if the async action failed

BUT … this is HARD with useReducer

A useReducer implementation  might look something like:

type ActionType =
      | { type: "LOADING" }
      | { type: "ADD_SUCCESS", payload: number }
      | { type: "ADD_FAILURE", payload: any };

    type StateType = {
      count: number,
      isActive: boolean,
      error: any,
    };

    const initialState = {
      count: 0,
      isActive: false,
      error: null,
    };

    function Counter() {
      const [{count, isActive, error}, dispatch] = useReducer(
        (state: StateType, action: ActionType) => {
          switch (action.type) {
            case "LOADING":
              return {
                ...state,
                isActive: true,
              };
            case "ADD_SUCCESS":
              return {
                ...state,
                count: state.count + action.payload,
                isActive: false,
                error: null,
              };
            case "ADD_FAILURE":
              return {
                ...state,
                isActive: false,
                error: action.payload,
              };
            default:
              return state;
          }
        },
        initialState
      );

      const add = (amount: number) => {
        dispatch({ type: "LOADING" });
        // An api call to update the count state on the server
        updateCounterOnServer(state.count + amount)
          .then(() => {
            dispatch({ type: "ADD_SUCCESS", payload: amount });
          })
          .catch((error) => {
            dispatch({ type: "ADD_FAILURE", payload: error });
          });
      };
      return (
        <div>
          <button onClick={() => add(2)}>Add</button>
          <div>
            <p>Steps: {count}</p>
            <div>{isActive ? <Loader /> : "Processing completed"}</div>
            {error && <p>Error: {error}</p>}
          </div>
        </div>
      );
    }

This is much more simple with useSimpleReducer:

type CounterState = { count: number };

    const initialState = {
      count: 0,
    };

    function Counter() {
      const [state, actions, queue, error] = useSimpleReducer(
        // initial state
        initialState,
        // collection of reducer methods
        {
          async add(state: CounterState, amount: number) {
            // An api call to update the count state on the server
            await updateCounterOnServer(state.count + amount);
            return { ...state, count: state.count + amount };
          },
        }
      );
      return (
        <div>
          <button onClick={() => actions.add(2)}>Add</button>
          <div>
            <p>Steps: {state.count}</p>
            <div>{queue.isActive ? <Loader /> : "Processing completed"}</div>
            {error && <p>{error.reason}</p>}
          </div>
        </div>
      );
    }

Looks quite a bit cleaner, right?  Here is why:

  1. Logic is now encapsulated in separate methods, rather than in one giant switch statement. Instead of having to extract a payload from our action object, we can use simple function parameters.
  2. Instead of getting back a one-size-fits-all dispatch function, we get back a set of callbacks actions, one for each of our "actions". 

And you get queuing,  error handling, and type checking for free!

Queueing

Instead of dispatching actions, the user can use the actions value to call the reducer methods provided.

Any invoked reducer action gets added to a queue. The queue will then start processing those asynchronous actions in the same order they have been added.

An queue.isActive flag indicates whether the queue is currently processing any actions or not.

A set of values queue.runningAction and queue.pendingActions are also exposed that can be used for debugging the current state of the queue.

Error handling

The useSimpleReducer hook returns an error if any of the reducer methods fail.

This error object exposes a number of recovery methods that provide the flexibility for the user to run the failed action, pending actions, or all of them.

return (
        <div>
            <button onClick={()=> actions.add(2)}>Add</button>
            <div>
                <p>Steps: {state.count}</p>
                <div>{queue.isActive ?  : "Processing completed"}</div>
            </div>
            {error && <AlertDialog content={error.reason} onConfirm={() => error.runFailedAction()} />}
        </div>
    );

An in-depth explanation of these values can be found in the API documentation on Github.

Final thoughts

I know it's a very common pattern in the industry to use a useReducer. But I believe that useSimpleReducer does it better in a way that is more intuitive to understand while offering extra capabilities.

You can try out the demo or install the package from NPM.

21