Pre-requisites

This article assumes that the reader is familiar with ReactJS, react hooks and a little about redux and redux-thunk

History

Before the advent of the context API, people used to use redux to manage the state of their react apps. One cool feature that redux provided was the redux-thunk middleware. For an idea on why you might want to use this middleware check this link.

Long story short, what you want to do usually is separate how you model your state from how you handle the user interaction.

In this article we are going to learn how to get this behaviour back using the context API and the useReducer hook

Learn by example

We will be making a simple todo app that fetches todos from typicode, displays them, lets the user mark todos as complete or not and gives the possibility to refresh todos.

Small note: You can get all the code showed in this article in this github repository

setting up the repo

First let's make a new repository that will hold our app

mkdir async-reducer && cd async-reducer
yarn init # or npm init

Answer the questions asked by yarn (or simply keep hitting enter) then let's proceed to create the dependencies we need.

yarn add react react-dom 

Then we will add a simple bundler and the types our IDE might need to help us with things like completion

yarn add -D parcel-bundler @types/react @types/react-dom

Then let's add a simple start script in our package.json

// package.json
"scripts": {
  "start": "parcel index.html"
}

The end package.json should look like this

{
  "name": "async-reducer",
  "version": "1.0.0",
  "main": "index.js",
  "author": "your@mail.com",
  "license": "MIT",
  "private": false,
  "scripts": {
    "start": "parcel index.html"
  },
  "dependencies": {
    "react": "^16.12.0",
    "react-dom": "^16.12.0"
  },
  "devDependencies": {
    "@types/react": "^16.9.19",
    "@types/react-dom": "^16.9.5",
    "parcel-bundler": "^1.12.4"
  }
}

Code

Let's start by writing a simple index.html file that will hold our react app

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>Async Reducer Example</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="index.js"></script>
  </body>
</html>

Then let's create a simple index.js file that will render our react app

import ReactDOM from "react-dom";
import * as React from "react";
import App from "./App";

ReactDOM.render(<App />, document.getElementById("app"));

Now let's create our App.js file:

import * as React from "react";

const apiUrl = "https://jsonplaceholder.typicode.com/todos";
const App = () => {
  const [todos, setTodos] = React.useState([]);
  const [shouldFetch, setShouldFetch] = React.useState(true);
  React.useEffect(() => {
    if (shouldFetch) {
      setShouldFetch(false);
      console.log("fetching");
      fetch(apiUrl)
        .then(response => response.json())
        .then(json => {
          const todos = json.slice(0, 9);
          setTodos(todos);
          console.log("done fetching");
        })
        .catch(error => console.log("error", error));
    }
  }, [shouldFetch, todos]);
  return (
    <>
      <div
        style={{
          marginTop: "25px",
          display: "flex",
          flexDirection: "row",
          justifyContent: "center"
        }}
      >
        <button onClick={_ => setShouldFetch(true)}> Refetch todos </button>
      </div>
      <div
        style={{
          marginTop: "25px",
          display: "flex",
          flexDirection: "row",
          justifyContent: "center"
        }}
      >
        <ul>
          {todos.map(todo => (
            <li key={todo.id} style={{ marginBottom: "15px" }}>
              Title: {todo.title} <br />
              Completed:{" "}
              <input
                checked={todo.completed}
                type="checkbox"
                onChange={_ =>
                  setTodos(
                    todos.map(obj => {
                      if (obj.id === todo.id) {
                        return {
                          ...obj,
                          completed: !obj.completed
                        };
                      } else {
                        return obj;
                      }
                    })
                  )
                }
              />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
};

export default App;

Now in a new terminal, under the same directory that holds our repository run the following command

yarn start

then open your browser on localhost:1234

Now this version of the application does indeed what it needs to do, but our component does a bit too much, it does the display (returns jsx), handles user interaction by changing directly the state of the application (by using setTodos and setShouldFetch).

We can better separate the display logic from the logic that handles our users interaction by using the useReducer hook. Ideally we would love to use the useReducer hook like this in our App.js file:

const reducer = (state, action) => {
  switch (action.type) {
    case "FETCH_TODOS": {
      console.log("fetching");
      const todos = await  fetch(apiUrl)
      .then(response => response.json())
        .then(json => {
          const todos = json.slice(0, 9);
          console.log("done fetching");
          return todos;
        })
        .catch(error => console.log("error", error));
      return  {
        ...state,
        todos
      };
      ...
    }
  }
}

The only problem is that our reducer has to be pure. Meaning no side-effects, no fetch, no mutation ...
Meaning our reducer can only handle state changes, but not do fetch requests. Which is a good thing since it makes for a better separation of concerns:

  • the component handles the display logic
  • the reducer handles state changes
    Now we need something that would handle user actions. But we can already handle user action that do not need to be aynchronous like changing the completed state of a todo. So let's build something that is a bit cleaner with a reducer.

Let's add this just after the declaration of const apiUrl = ...

const reducer = (state, action) => {
  switch (action.type) {
    case "UPDATE_TODOS": {
      return { ...state, todos: action.payload };
    }
    case "UPDATE_TODO_COMPLETED": {
      return {
        ...state,
        todos: state.todos.map(todo => {
          if (todo.id === action.payload) {
            return {
              ...todo,
              completed: !todo.completed
            };
          } else {
            return todo;
          }
        })
      };
    }
  }
};

Let's update the definition of our App function like so:

const App = () => {
  // we use the useReducer hook instead of useState
  const [state, dispatch] = React.useReducer(reducer, {
    todos: []
  });
  const todos = state.todos;
  // We only load the todos at the first render
  React.useEffect(() => {
    console.log("fetching");
    fetch(apiUrl)
      .then(response => response.json())
      .then(json => {
        const todos = json.slice(0, 9);
        dispatch({ type: "UPDATE_TODOS", payload: todos });
        console.log("done fetching");
      })
      .catch(error => console.log("error", error));
  }, []);
  return (
    <>
      <div
        style={{
          marginTop: "25px",
          display: "flex",
          flexDirection: "row",
          justifyContent: "center"
        }}
      >
        {/* This buttons does nothing for now 
            We will see how we can refresh todos later
        */}
        <button onClick={_ => {}}> Refetch todos </button>
      </div>
      <div
        style={{
          marginTop: "25px",
          display: "flex",
          flexDirection: "row",
          justifyContent: "center"
        }}
      >
        <ul>
          {todos.map(todo => (
            <li key={todo.id} style={{ marginBottom: "15px" }}>
              Title: {todo.title} <br />
              Completed:{" "}
              <input
                checked={todo.completed}
                type="checkbox"
                onChange={_ =>{
                  // We don't set the state directly
                  // We only dispatch an action
                  // and the reducer is the one 
                  // who updates the state
                  dispatch({ type: "UPDATE_TODO_COMPLETED", payload: todo.id })
                }}
              />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
};

Now for the exciting part, solving the asynchronous problem to better separate user actions handling from state changes management

The idea is simple, we use a middleware that takes the dispatch function and returns a modified version of it such that the new dispatch function can run some effects and then call the original dispatch function to change the state of the application when needed.
This way our state changes are always pure and synchronous, and we can still make network calls when we need to outside of our reducer.

Let's add the definition of our middleware like so just before the definition of App:

const middleware = dispatch => action => {
  switch (action.type) {
    case "FETCH_TODOS": {
      console.log("fetching");
      fetch(apiUrl)
        .then(response => response.json())
        .then(json => {
          const todos = json.slice(0, 9);
          console.log("done fetching");
          dispatch({ type: "UPDATE_TODOS", payload: todos });
        })
        .catch(error => console.log("error", error));
      break;
    }
    case "UPDATE_TODO_COMPLETED": {
      dispatch(action);
      break;
    }
  }
};

Here is the final version of our App definition

const App = () => {
  const [state, dispatch_] = React.useReducer(reducer, {
    todos: []
  });
  // we use the middleware to create the async-dispatch
  const dispatch = middleware(dispatch_);
  const todos = state.todos;
  // Our component does not do the fetching
  // It only tells the reducer to do it
  React.useEffect(() => {
    dispatch({ type: "FETCH_TODOS" });
  }, []);
  return (
    <>
      <div
        style={{
          marginTop: "25px",
          display: "flex",
          flexDirection: "row",
          justifyContent: "center"
        }}
      >
       {/*
        The refresh button sends an action to the reducer
        to fetch the todos again
       */}
        <button onClick={_ => dispatch({ type: "FETCH_TODOS" })}>
          {" "}
          Refetch todos{" "}
        </button>
      </div>
      <div
        style={{
          marginTop: "25px",
          display: "flex",
          flexDirection: "row",
          justifyContent: "center"
        }}
      >
        <ul>
          {todos.map(todo => (
            <li key={todo.id} style={{ marginBottom: "15px" }}>
              Title: {todo.title} <br />
              Completed:{" "}
              <input
                checked={todo.completed}
                type="checkbox"
                onChange={_ => {
                  dispatch({ type: "UPDATE_TODO_COMPLETED", payload: todo.id });
                }}
              />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
};

Now our App function only does the display of the todos, our middleware handles user actions and the reducer handles internal state changes. We respect well the separation of concerns principle and we do keep our components clean.

Here is the final version of the App.js file:

import * as React from "react";

const apiUrl = "https://jsonplaceholder.typicode.com/todos";

const reducer = (state, action) => {
  switch (action.type) {
    case "UPDATE_TODOS": {
      return { ...state, todos: action.payload };
    }
    case "UPDATE_TODO_COMPLETED": {
      return {
        ...state,
        todos: state.todos.map(todo => {
          if (todo.id === action.payload) {
            return {
              ...todo,
              completed: !todo.completed
            };
          } else {
            return todo;
          }
        })
      };
    }
  }
};

const middleware = dispatch => action => {
  switch (action.type) {
    case "FETCH_TODOS": {
      console.log("fetching");
      fetch(apiUrl)
        .then(response => response.json())
        .then(json => {
          const todos = json.slice(0, 9);
          console.log("done fetching");
          dispatch({ type: "UPDATE_TODOS", payload: todos });
        })
        .catch(error => console.log("error", error));
      break;
    }
    case "UPDATE_TODO_COMPLETED": {
      dispatch(action);
      break;
    }
  }
};

const App = () => {
  const [state, dispatch_] = React.useReducer(reducer, {
    todos: []
  });
  const dispatch = middleware(dispatch_);
  const todos = state.todos;
  React.useEffect(() => {
    dispatch({ type: "FETCH_TODOS" });
  }, []);
  return (
    <>
      <div
        style={{
          marginTop: "25px",
          display: "flex",
          flexDirection: "row",
          justifyContent: "center"
        }}
      >
        <button onClick={_ => dispatch({ type: "FETCH_TODOS" })}>
          {" "}
          Refetch todos{" "}
        </button>
      </div>
      <div
        style={{
          marginTop: "25px",
          display: "flex",
          flexDirection: "row",
          justifyContent: "center"
        }}
      >
        <ul>
          {todos.map(todo => (
            <li key={todo.id} style={{ marginBottom: "15px" }}>
              Title: {todo.title} <br />
              Completed:{" "}
              <input
                checked={todo.completed}
                type="checkbox"
                onChange={_ => {
                  dispatch({ type: "UPDATE_TODO_COMPLETED", payload: todo.id });
                }}
              />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
};

export default App;

Shoutout: This idea of using a middleware is not mine, all the props go to creativity of the people who proposed this solution here , more specifically solution 3 in that gist.

Small note:
Once your store gets bigger you might start running into issues of forgetting types of your actions, or using the wrong actions ... etc. You might want to start looking into using something like Typescript or ReasonML that would help you with these kind of issues.