What You'll Learn
Prerequisites
In Part 5: UI and React, we saw how to use the React-Redux library to let our React components interact with a Redux store, including calling useSelector
to read Redux state, calling useDispatch
to give us access to the dispatch
function, and wrapping our app in a <Provider>
component to give those hooks access to the store.
So far, all the data we've worked with has been directly inside of our React+Redux client application. However, most real applications need to work with data from a server, by making HTTP API calls to fetch and save items.
In this section, we'll update our todo app to fetch the todos from an API, and add new todos by saving them to the API.
caution
Note that this tutorial intentionally shows older-style Redux logic patterns that require more code than the "modern Redux" patterns with Redux Toolkit we teach as the right approach for building apps with Redux today, in order to explain the principles and concepts behind Redux. It's not meant to be a production-ready project.
See these pages to learn how to use "modern Redux" with Redux Toolkit:
tip
Redux Toolkit includes the RTK Query data fetching and caching API. RTK Query is a purpose built data fetching and caching solution for Redux apps, and can eliminate the need to write any thunks or reducers to manage data fetching. We specifically teach RTK Query as the default approach for data fetching, and RTK Query is built on the same patterns shown in this page.
Learn how to use RTK Query for data fetching in Redux Essentials, Part 7: RTK Query Basics.
Example REST API and ClientTo keep the example project isolated but realistic, the initial project setup already included a fake in-memory REST API for our data (configured using the Mirage.js mock API tool). The API uses /fakeApi
as the base URL for the endpoints, and supports the typical GET/POST/PUT/DELETE
HTTP methods for /fakeApi/todos
. It's defined in src/api/server.js
.
The project also includes a small HTTP API client object that exposes client.get()
and client.post()
methods, similar to popular HTTP libraries like axios
. It's defined in src/api/client.js
.
We'll use the client
object to make HTTP calls to our in-memory fake REST API for this section.
By itself, a Redux store doesn't know anything about async logic. It only knows how to synchronously dispatch actions, update the state by calling the root reducer function, and notify the UI that something has changed. Any asynchronicity has to happen outside the store.
Earlier, we said that Redux reducers must never contain "side effects". A "side effect" is any change to state or behavior that can be seen outside of returning a value from a function. Some common kinds of side effects are things like:
Math.random()
or Date.now()
)However, any real app will need to do these kinds of things somewhere. So, if we can't put side effects in reducers, where can we put them?
Redux middleware were designed to enable writing logic that has side effects.
As we said in Part 4, a Redux middleware can do anything when it sees a dispatched action: log something, modify the action, delay the action, make an async call, and more. Also, since middleware form a pipeline around the real store.dispatch
function, this also means that we could actually pass something that isn't a plain action object to dispatch
, as long as a middleware intercepts that value and doesn't let it reach the reducers.
Middleware also have access to dispatch
and getState
. That means you could write some async logic in a middleware, and still have the ability to interact with the Redux store by dispatching actions.
Let's look at a couple examples of how middleware can enable us to write some kind of async logic that interacts with the Redux store.
One possibility is writing a middleware that looks for specific action types, and runs async logic when it sees those actions, like these examples:
import { client } from '../api/client'
const delayedActionMiddleware = storeAPI => next => action => {
if (action.type === 'todos/todoAdded') {
setTimeout(() => {
next(action)
}, 1000)
return
}
return next(action)
}
const fetchTodosMiddleware = storeAPI => next => action => {
if (action.type === 'todos/fetchTodos') {
client.get('todos').then(todos => {
storeAPI.dispatch({ type: 'todos/todosLoaded', payload: todos })
})
}
return next(action)
}
Writing an Async Function Middleware
Both of the middleware in that last section were very specific and only do one thing. It would be nice if we had a way to write any async logic ahead of time, separate from the middleware itself, and still have access to dispatch
and getState
so that we can interact with the store.
What if we wrote a middleware that let us pass a function to dispatch
, instead of an action object? We could have our middleware check to see if the "action" is actually a function instead, and if it's a function, call the function right away. That would let us write async logic in separate functions, outside of the middleware definition.
Here's what that middleware might look like:
Example async function middleware
const asyncFunctionMiddleware = storeAPI => next => action => {
if (typeof action === 'function') {
return action(storeAPI.dispatch, storeAPI.getState)
}
return next(action)
}
And then we could use that middleware like this:
const middlewareEnhancer = applyMiddleware(asyncFunctionMiddleware)
const store = createStore(rootReducer, middlewareEnhancer)
const fetchSomeData = (dispatch, getState) => {
client.get('todos').then(todos => {
dispatch({ type: 'todos/todosLoaded', payload: todos })
const allTodos = getState().todos
console.log('Number of todos after loading: ', allTodos.length)
})
}
store.dispatch(fetchSomeData)
Again, notice that this "async function middleware" let us pass a function to dispatch
! Inside that function, we were able to write some async logic (an HTTP request), then dispatch a normal action object when the request completed.
So how do middleware and async logic affect the overall data flow of a Redux app?
Just like with a normal action, we first need to handle a user event in the application, such as a click on a button. Then, we call dispatch()
, and pass in something, whether it be a plain action object, a function, or some other value that a middleware can look for.
Once that dispatched value reaches a middleware, it can make an async call, and then dispatch a real action object when the async call completes.
Earlier, we saw a diagram that represents the normal synchronous Redux data flow. When we add async logic to a Redux app, we add an extra step where middleware can run logic like HTTP requests, then dispatch actions. That makes the async data flow look like this:
Using the Redux Thunk MiddlewareAs it turns out, Redux already has an official version of that "async function middleware", called the Redux "Thunk" middleware. The thunk middleware allows us to write functions that get dispatch
and getState
as arguments. The thunk functions can have any async logic we want inside, and that logic can dispatch actions and read the store state as needed.
Writing async logic as thunk functions allows us to reuse that logic without knowing what Redux store we're using ahead of time.
Configuring the StoreThe Redux thunk middleware is available on NPM as a package called redux-thunk
. We need to install that package to use it in our app:
Once it's installed, we can update the Redux store in our todo app to use that middleware:
src/store.js
import { createStore, applyMiddleware } from 'redux'
import { thunk } from 'redux-thunk'
import { composeWithDevTools } from 'redux-devtools-extension'
import rootReducer from './reducer'
const composedEnhancer = composeWithDevTools(applyMiddleware(thunk))
const store = createStore(rootReducer, composedEnhancer)
export default store
Fetching Todos from a Server
Right now our todo entries can only exist in the client's browser. We need a way to load a list of todos from the server when the app starts up.
We'll start by writing a thunk function that makes an HTTP call to our /fakeApi/todos
endpoint to request an array of todo objects, and then dispatch an action containing that array as the payload. Since this is related to the todos feature in general, we'll write the thunk function in the todosSlice.js
file:
src/features/todos/todosSlice.js
import { client } from '../../api/client'
const initialState = []
export default function todosReducer(state = initialState, action) {
}
export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
}
We only want to make this API call once, when the application loads for the first time. There's a few places we could put this:
<App>
component, in a useEffect
hook<TodoList>
component, in a useEffect
hookindex.js
file directly, right after we import the storeFor now, let's try putting this directly in index.js
:
src/index.js
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import './index.css'
import App from './App'
import './api/server'
import store from './store'
import { fetchTodos } from './features/todos/todosSlice'
store.dispatch(fetchTodos)
const root = createRoot(document.getElementById('root'))
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
If we reload the page, there's no visible change in the UI. However, if we open up the Redux DevTools extension, we should now see that a 'todos/todosLoaded'
action was dispatched, and it should contain some todo objects that were generated by our fake server API:
Notice that even though we've dispatched an action, nothing's happening to change the state. We need to handle this action in our todos reducer to have the state updated.
Let's add a case to the reducer to load this data into the store. Since we're fetching the data from the server, we want to completely replace any existing todos, so we can return the action.payload
array to make it be the new todos state
value:
src/features/todos/todosSlice.js
import { client } from '../../api/client'
const initialState = []
export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todosLoaded': {
return action.payload
}
default:
return state
}
}
export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
}
Since dispatching an action immediately updates the store, we can also call getState
in the thunk to read the updated state value after we dispatch. For example, we could log the number of total todos to the console before and after dispatching the 'todos/todosLoaded'
action:
export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
const stateBefore = getState()
console.log('Todos before dispatch: ', stateBefore.todos.length)
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
const stateAfter = getState()
console.log('Todos after dispatch: ', stateAfter.todos.length)
}
Saving Todo Items
We also need to update the server whenever we try to create a new todo item. Instead of dispatching the 'todos/todoAdded'
action right away, we should make an API call to the server with the initial data, wait for the server to send back a copy of the newly saved todo item, and then dispatch an action with that todo item.
However, if we start trying to write this logic as a thunk function, we're going to run into a problem: since we're writing the thunk as a separate function in the todosSlice.js
file, the code that makes the API call doesn't know what the new todo text is supposed to be:
src/features/todos/todosSlice.js
async function saveNewTodo(dispatch, getState) {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch({ type: 'todos/todoAdded', payload: response.todo })
}
We need a way to write one function that accepts text
as its parameter, but then creates the actual thunk function so that it can use the text
value to make the API call. Our outer function should then return the thunk function so that we can pass to dispatch
in our component.
src/features/todos/todosSlice.js
export function saveNewTodo(text) {
return async function saveNewTodoThunk(dispatch, getState) {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch({ type: 'todos/todoAdded', payload: response.todo })
}
}
Now we can use this in our <Header>
component:
src/features/header/Header.js
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'
import { saveNewTodo } from '../todos/todosSlice'
const Header = () => {
const [text, setText] = useState('')
const dispatch = useDispatch()
const handleChange = e => setText(e.target.value)
const handleKeyDown = e => {
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
const saveNewTodoThunk = saveNewTodo(trimmedText)
dispatch(saveNewTodoThunk)
setText('')
}
}
}
Since we know we're going to immediately pass the thunk function to dispatch
in the component, we can skip creating the temporary variable. Instead, we can call saveNewTodo(text)
, and pass the resulting thunk function straight to dispatch
:
src/features/header/Header.js
const handleKeyDown = e => {
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
dispatch(saveNewTodo(trimmedText))
setText('')
}
}
Now the component doesn't actually know that it's even dispatching a thunk function - the saveNewTodo
function is encapsulating what's actually happening. The <Header>
component only knows that it needs to dispatch some value when the user presses enter.
This pattern of writing a function to prepare something that will get passed to dispatch
is called the "action creator" pattern, and we'll talk about that more in the next section.
We can now see the updated 'todos/todoAdded'
action being dispatched:
The last thing we need to change here is updating our todos reducer. When we make a POST request to /fakeApi/todos
, the server will return a completely new todo object (including a new ID value). That means our reducer doesn't have to calculate a new ID, or fill out the other fields - it only needs to create a new state
array that includes the new todo item:
src/features/todos/todosSlice.js
const initialState = []
export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
return [...state, action.payload]
}
default:
return state
}
}
And now adding a new todo will work correctly:
tip
Thunk functions can be used for both asynchronous and synchronous logic. Thunks provide a way to write any reusable logic that needs access to dispatch
and getState
.
We've now successfully updated our todo app so that we can fetch a list of todo items and save new todo items, using "thunk" functions to make the HTTP requests to our fake server API.
In the process, we saw how Redux middleware are used to let us make async calls and interact with the store by dispatching actions with after the async calls have completed.
Here's what the current app looks like:
Summary
dispatch
dispatch
and getState
, so they can dispatch more actions as part of async logicdispatch
dispatch
and getState
as arguments, and can dispatch actions like "this data was received from an API response"We've now covered all the core pieces of how to use Redux! You've seen how to:
In Part 7: Standard Redux Patterns, we'll look at several code patterns that are typically used by real-world Redux apps to make our code more consistent and scale better as the application grows.
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4