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.