If you’ve kept up so far with the book, or completed a few of the more basic tutorials online, you know that we’ve reached a point where things traditionally start to get a little trickier. Here’s where you are: a user interacts with the app and actions get dispatched to reflect certain events, such as creating a new task or editing an existing task. The data lives directly in the browser, which means you lose any progress if the user refreshes the page.
Perhaps without realizing, every action you’ve dispatched so far has been synchronous. When an action is dispatched, the store receives it immediately. These kinds of actions are straightforward, and synchronous code is generally easier to work with and think about. You know exactly the order things will execute based on where the code is defined. You execute line one and then line two, and so on. But you’d be hard-pressed to find any real-world JavaScript application that didn’t involve any asynchronous code. It’s fundamental to the language and required to do what this chapter is all about: talking with a server.
To repeat a few fundamental Redux ideas
Every action you dispatched in chapters 2 and 3 was synchronous, but, as it turns out, you often need a server to do anything useful. Specifically, you need to make AJAX requests perform actions such as fetch tasks when the app starts and save new tasks to a server, so they’ll persist between page refreshes. Virtually every real-world application is backed by a server, so clear patterns for interacting with an API are crucial for an application’s long-term health.
Figure 4.1 recaps how your dispatches have looked so far. You dispatch an action from the view, and the action is received immediately by the store. All this code is synchronous, meaning each operation will run only after the previous operation has completed.
By contrast, asynchronous, or async, actions are where you can add asynchronous code such as AJAX requests. When dispatching synchronous actions, you don’t have any room for extra functionality. Async actions provide a way to handle asynchronous operations and dispatch synchronous actions with the results when they become available. Async actions typically combine the following into one convenient package, which you can dispatch directly within your app:
Say you want to fetch a list of tasks from the server to render on the page when the app loads for the first time. You need to initiate the request, wait for the server to respond, and dispatch an action with any results. Figure 4.2 uses a fetchTasks async action, which you’ll implement later in the chapter. Notice the delay between the initial async action being dispatched and when the store finally receives an action to process.
Much of the confusion around async actions boils down to terminology. In this chapter (and throughout the book), we’ll try to be specific about the terms we use, to avoid overloading certain language. Here are the fundamental concepts along with examples:
{ type: 'FETCH_TASKS_SUCCEEDED', payload: { tasks: [...] } }
function fetchTasksSucceeded(tasks) { return { type: 'FETCH_TASKS_SUCCEEDED', payload: { tasks: [...] } }
export function fetchTasks() { return dispatch => { api.fetchTasks().then(resp => { dispatch(fetchTasksSucceeded(resp.data)); }); }; }
The syntax from the last example is a sneak peek at what’s ahead. Soon we’ll introduce the redux-thunk package, which allows you to dispatch functions in addition to standard action objects. Before you can dig into implementing async action creators, you need a simple server. Please go to the appendix and follow the instructions for setting up that server and installing two more dependencies: axios and redux-thunk. Don’t miss the important tweak required in the Redux DevTools configuration either! When you’ve finished, go on to the next section to learn how to dispatch async actions.
You know you can pass an action object to the dispatch function, which will pass the action to the store and apply any updates. What if you don’t want the action to be processed immediately? What if you want to make a GET request for tasks and dispatch an action with the data from the response body? The first async action you’ll dispatch is to fetch a list of tasks from the server when the app loads. At a high level, you’ll do the following:
We’ve been going on about async actions, but we’ve yet to show you the mechanism to accomplish them. You can transition the fetchTasks action creator from synchronous to asynchronous by returning a function instead of an object. The function you return from fetchTasks can safely perform a network request and dispatch a synchronous action with response data.
It’s possible to do this without another dependency, but you’ll find that the code to facilitate it may quickly become unmanageable. For starters, each of your components will need to be aware if they’re making a synchronous or asynchronous call, and if the latter, pass along the dispatch functionality.
The most popular option for handling async actions is redux-thunk, a Redux middleware. Understanding the ins and outs of middleware isn’t necessary now; we’ll cover middleware in depth in chapter 5. The most important takeaway is that adding the redux-thunk middleware allows us to dispatch functions as well as the standard action objects that you’re already used to. Within these functions, you’re safe to add any asynchronous code you might need.
At this point, you have a functioning HTTP API, an AJAX library (axios), and the redux-thunk middleware, which will allow you to dispatch functions instead of action objects when you need to perform async operations such as network requests.
Currently, you’re rendering the page with a static list of tasks defined in the tasks reducer. Start by removing the list of mock tasks in src/reducers/index.js and adjust the initial state for the reducer, as shown in the following listing.
import { uniqueId } from '../actions' 1 1 const mockTasks = [ 1 { id: uniqueId(), title: 'Learn Redux', description: 'The store, actions, and reducers, oh my!', status: 'Unstarted', }, { id: uniqueId(), title: 'Peace on Earth', description: 'No big deal.', status: 'In Progress', }, { id: uniqueId(), title: 'Foo', description: 'Bar', status: 'Completed', }, ]; export function tasks(state = { tasks: [] }, action) { 2 ...
At a high-level, here’s what you need to add to fetch a list of tasks via AJAX:
Figure 4.3 shows the fetchTasks async action creator you’re about to create in more detail.
As with synchronous actions, the goal of dispatching fetchTasks as an async action is to load tasks into the store, so you can render them on the page. The only difference here is that you fetch them from a server instead of relying on mock tasks defined directly in the code.
Keeping figure 4.3 in mind, let’s start from left to right by dispatching an action from the view. Import a soon-to-be-created fetchTasks action creator and dispatch it within the componentDidMount lifecycle method.
import React, { Component } from 'react'; import { connect } from 'react-redux'; import TasksPage from './components/TasksPage'; import { createTask, editTask, fetchTasks } from './actions'; 1 class App extends Component { componentDidMount() { this.props.dispatch(fetchTasks()); 2 } ... render() { return ( <div className="main-content"> <TasksPage tasks={this.props.tasks} onCreateTask={this.onCreateTask} onStatusChange={this.onStatusChange} /> </div> ); } } function mapStateToProps(state) { return { tasks: state.tasks }; }
It’s useful to think about apps in terms of data dependencies. You should ask yourself, “What data does this page or page section need to render successfully?” When you’re working in the browser, componentDidMount is the appropriate lifecycle callback to initiate AJAX requests. Because it’s configured to run whenever a component is first mounted to the DOM, it’s at this point that you can begin to fetch the data to populate that DOM. Furthermore, this is an established best practice in React.
Next, head to src/actions/index.js, where you’ll need two things:
Use axios to perform the AJAX request. Upon a successful response, the body of that response will be passed to the synchronous action creator to be dispatched, as shown in the following listing.
import axios from 'axios'; ... export function fetchTasksSucceeded(tasks) { 1 return { type: 'FETCH_TASKS_SUCCEEDED', payload: { tasks } } } export function fetchTasks() { return dispatch => { 2 axios.get('http://localhost:3001/tasks') 3 .then(resp => { dispatch(fetchTasksSucceeded(resp.data)); 4 }); } }
The biggest shift in listing 4.3 from any of the actions you’ve worked with so far is that fetchTasks returns a function, not an action object. The redux-thunk middleware is what makes this possible. If you attempted to dispatch a function without the middleware applied, Redux would throw an error because it expects an object to be passed to dispatch.
Within this dispatched function, you’re free to do the following:
Most async actions tend to share these basic responsibilities.
While you let async actions start to sink in, let’s cover a common abstraction for interacting with servers. In listing 4.3, you used axios for the first time to make a GET request to the /tasks endpoint. This is fine for the time being, but as your application grows you’ll start to run into a few issues. What if you change the base URL for the API from localhost:3001 to localhost:3002? What if you want to use a different AJAX library? You have to update that code in only one place now, but imagine if you had 10 AJAX calls. What about 100?
To address these questions, you can abstract those details into an API client and give it a friendly interface. If you’re working with a team, future developers won’t have to worry about specifics around ports, headers, the AJAX library being used, and so on.
Create a new api/directory with a single index.js file. If you’re working in a large application with many difference resources, it can make sense to create multiple files per resource, but a single index.js file will be fine to get us started.
In src/api/index.js, create a fetchTasks function that will encapsulate your API call, and configure axios with basic headers and a base URL, as shown in the following listing.
import axios from 'axios'; const API_BASE_URL = 'http://localhost:3001'; 1 const client = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', 2 }, }); export function fetchTasks() { 3 return client.get('/tasks'); 3 } 3
Here you’re hardcoding the base URL for the API. In a real-world application, you’d likely get this from a server, so the value can be different based on the environment, like staging or production.
With fetchTasks, you’re encapsulating the request method as well as the URL for the endpoint. If either change, you only have to update code in one place. Note that axios.get returns a promise, which you can call .then and .catch on from within an async action creator.
Now that you’re exporting a fetchTasks function that wraps the API call, head back to src/actions/index.js and replace the existing API call with the new fetchTasks function, as shown in the following listing.
import * as api from '../api'; 1 ... export function fetchTasks() { return dispatch => { api.fetchTasks().then(resp => { 2 dispatch(fetchTasksSucceeded(resp.data)); }); }; } ...
Not only are the details of the request safely hidden away, the fetchTasks action creator is also clearer and more concise. By extracting an API client, you’ve improved encapsulation, future maintainability, and readability at the cost of the overhead of another module to manage. Creating new abstractions isn’t always the right answer, but in this case, it seems like a no-brainer.
You now know a few things about synchronous and asynchronous actions, but there’s one more concept that can help you form a clearer picture around how updates are happening in our applications. Typically you have two entities that can modify application state: users and servers. Actions can be divided into two groups, one for each actor: view actions and server actions.
Certain developers like to organize view and server actions in separate directories, with the argument being it can help to break up larger files. It’s not a requirement, and we won’t do it in this book.
Getting back to the code, you have a server action, FETCH_TASKS_SUCCEEDED, which you dispatch with the list of tasks sent back from the server. Server actions are initiated by a server event like a response, but they still behave like any other action. They get dispatched and then handled by a reducer.
Let’s wrap up your initial fetching logic by updating the tasks reducer to handle receiving tasks from the server. It’s also safe to remove the mockTasks array at this point. Because you can remove the mockTasks array, you can use an empty array as the initial state for the reducer, as shown in the following listing.
... export default function tasks(state = { tasks: [] }, action) { 1 ... if (action.type === 'FETCH_TASKS_SUCCEEDED') { 2 return { tasks: action.payload.tasks, }; } return state; }
Notice how you didn’t have to make any updates to the view? That’s by design! Your React components don’t particularly care where the tasks come from, which allows you to totally change your strategy for acquiring tasks with relatively low effort. Keep this in mind as you continue to move through the book. When possible, you should be building each piece of your app with a clear interface—changing one piece (such as how you get a list of tasks to initially render) shouldn’t affect another (the view).
At this point you’ve introduced most of the conceptual heavy lifting that chapter 4 has to offer, so let’s do a quick recap before you move on to persisting new tasks to the server. We’ve covered the following:
Now that you’re fetching tasks from the server when the app loads, let’s update creating tasks and editing tasks to be persisted on the server. The process will be similar to what you’ve seen with fetching tasks.
Let’s start by saving new tasks. You already have a framework in place: when a user fills out the form and submits, you dispatch the createTask action creator, the store receives the CREATE_TASK action, the reducer handles updating state, and the changes are broadcast back to the UI.
The createTask command needs to return a function instead of an object. Within that function, you can make your API call and dispatch an action when a response is available. Here’s a quick look at the high-level steps:
Remove the uniqueId function. It was originally meant as a stopgap until you could create tasks on a server, which would be responsible for adding an ID.
Now you’re there! Create a new function for creating tasks in the API client, as shown in the following listing.
... export function createTask(params) { return client.post('/tasks', params); 1 }
Now you can modify the createTask action creator to return a function, as shown in the following listing.
import * as api from '../api'; ... function createTaskSucceeded(task) { 1 return { type: 'CREATE_TASK_SUCCEEDED', payload: { task, }, }; } export function createTask({ title, description, status = 'Unstarted' }) { return dispatch => { api.createTask({ title, description, status }).then(resp => { dispatch(createTaskSucceeded(resp.data)); 2 }); }; }
You know that reducers handle updates to state, so update the tasks reducer to handle the CREATE_TASK_SUCCEEDED action. After that, you’ll be up to four action handlers, so now is as good a time as any to merge each if statement into a friendlier switch statement, as shown in the following listing. This is a common Redux pattern.
export default function tasks(state = { tasks: [] }, action) { switch (action.type) { 1 case 'CREATE_TASK': { return { tasks: state.tasks.concat(action.payload), }; } case 'EDIT_TASK': { const { payload } = action; return { tasks: state.tasks.map(task => { if (task.id === payload.id) { return Object.assign({}, task, payload.params); } return task; }), }; } case 'FETCH_TASKS_SUCCEEDED': { return { tasks: action.payload.tasks, }; } case 'CREATE_TASK_SUCCEEDED': { 2 return { tasks: state.tasks.concat(action.payload.task), }; } default: { return state; } } }
A switch statement is a slightly friendlier syntax when there are a significant number of cases to handle. This is the structure that you’ll typically see most often in Redux reducers, but using a switch statement isn’t a hard requirement.
Play around in the browser and create new tasks. When you refresh, they should appear again for the initial render. The big idea here is that the createTask action creator now returns a function instead of an object. The newly created task isn’t received by the store immediately after being dispatched, but instead is dispatched to the store after the POST request to /tasks has completed.
Task updates are the last feature you need to hook up to the server. The process for fetching, creating, and now editing tasks is nearly identical. This exercise is a good way to test whether you’re ready to connect the dots on your own.
We’ll outline the high-level steps needed to make everything work, but as a challenge, see if you can implement the code before glancing through the listings in the solution section. The requirements are the following:
The first thing you’ll do is add a new API function, editTask, as shown in the following listing.
export function editTask(id, params) { return axios.put(`${API_BASE_URL}/tasks/${id}`, params); 1 }
Now that you have a function you can import to make the right AJAX request, create a new async action creator to make the request to the server, and a new synchronous action creator to indicate the request has completed, as shown in the following listing.
... function editTaskSucceeded(task) { 1 return { type: 'EDIT_TASK_SUCCEEDED', payload: { task, }, }; } export function editTask(id, params = {}) { return (dispatch, getState) => { const task = getTaskById(getState().tasks.tasks, id); 2 const updatedTask = Object.assign({}, task, params); 2 api.editTask(id, updatedTask).then(resp => { dispatch(editTaskSucceeded(resp.data)); }); }; } function getTaskById(tasks, id) { return tasks.find(task => task.id === id); }
For each action that requires a network request (meaning you’re dealing with an async action), you’ll need at least one synchronous action creator to indicate where you are in the request/response lifecycle. Here, that’s editTaskSucceeded, which indicates the request has completed successfully and passes data from the response body on to the reducer.
Because json-server requires a full object to be passed along for PUT requests, you must grab the task out of the store and merge in the new properties yourself, as shown in the following listing.
export default function tasks(state = { tasks: [] }, action) { switch (action.type) { ... case 'EDIT_TASK_SUCCEEDED': { 1 const { payload } = action; return { tasks: state.tasks.map(task => { if (task.id === payload.task.id) { 2 return payload.task; } return task; }), }; } default: { return state; } } }
As you may have noticed, the process for saving updates to tasks is similar to the process for creating tasks. All interactions that trigger an async operation (usually a network request) tend to have these same high-level happenings:
Go ahead and update a few tasks. The UI should be responsive, given that your server is running locally, and the work that the server is doing is inexpensive. But that won’t always be the case. Requests in real-world applications inevitably will take longer due to latency or expensive operations, which means you need a kind of user feedback while a request is completing. Segue!
As UI programmers, you always want to keep your users well-informed of what’s happening in the application. Users have an expectation that certain things take time to complete, but they won’t forgive being left in the dark. When creating user experiences, user confusion should be one of the primary things you try to eliminate completely.
Enter loading states! You’ll use Redux to track the status of a request and update the UI to render the proper feedback when a request is in progress. One obvious place to start is during the initial fetch for tasks when the page loads.
With network requests, there are two moments in time that you care about: when the request starts, and when it completes. If you model these events as actions, you end up with three distinct action types that help describe the request-response lifecycle. Using fetching tasks as an example, note the following are the three action types:
Right now, the fetchTasks action creator accounts for only one moment in time, when the request completes. Figure 4.4 is what fetchTasks might look like if you also want to track when the request was initiated.
Now your async action creator, fetchTasks, will be responsible for three things:
Think of async actions as orchestrations. They typically perform several individual tasks in pursuit of fulfilling a larger goal. Your goal is to fetch a list of tasks, but it takes several steps to get there. That’s the role of the fetchTasks async action creator: to kick off everything that needs to get done to accomplish that goal.
Your store has only a single property, tasks:
{ tasks: [...] }
To render a loading indicator when a request for tasks is in progress, you need to keep track of more state. Here’s how you want the store to track request status:
{ tasks: { isLoading: false, tasks: [...] } }
This structure is much more flexible, because it allows you to group the task objects, along with any kind of additional state or metadata. In any real-world application, you’d be much more likely to come across a structure like this. The specific naming convention of the keys is only one pattern you can choose to adopt, but you’re not obligated to use it.
You have a few things to take care of in the code to move toward this new state structure. To start, update the tasks reducer to take a new initial state, and update the existing action handlers, as shown in the following listing.
const initialState = { 1 tasks: [], 1 isLoading: false, 1 }; 1 export default function tasks(state = initialState, action) { switch (action.type) { case 'FETCH_TASKS_SUCCEEDED': { return { ...state, 2 isLoading: false, 2 tasks: action.payload.tasks, 2 }; } case 'CREATE_TASK_SUCCEEDED': { return { ...state, 3 tasks: state.tasks.concat(action.payload.task), }; } case 'EDIT_TASK_SUCCEEDED': { const { payload } = action; const nextTasks = state.tasks.map(task => { if (task.id === payload.task.id) { return payload.task; } return task; }); return { ...state, 3 tasks: nextTasks, }; } default: { return state; } } }
First, you made sure to set the isLoading flag to false by default when you defined the initial state for the reducer. This is always good practice, because it prevents any loading indicators from rendering when they’re not supposed to. Let other actions that indicate a request has started set a flag like this to true.
When you handle the FETCH_TASKS_SUCCEEDED action, the obvious change is to update the array of tasks. You also have to remember to indicate the request is complete, so that any loading indicators are hidden, which you did by toggling the isLoading flag to false.
One important update you need to make is all the way up in the creation of the store, in index.js. Previously, you passed the tasks reducer directly to createStore. This was fine when all you had in your state object was a single array of tasks, but now you’re moving toward a more complete structure with additional state that lives alongside the task objects themselves.
Create a small root reducer that takes the entire contents of the store (state) and the action being dispatched (action), and passes only the piece of the store that the tasks reducer cares about, state.tasks, as shown in the following listing.
... import tasksReducer from './reducers'; const rootReducer = (state = {}, action) => { 1 return { tasks: tasksReducer(state.tasks, action), 2 }; }; const store = createStore( rootReducer, composeWithDevTools(applyMiddleware(thunk)), );
Adding a root reducer like this sets you up for the future as well. As you add more features to Parsnip, and, as a result, have more data to track in Redux, you can add new top-level properties to the store and create reducers that operate only on relevant data. Eventually you’ll add the ability for users to have different projects, and each project will have its own tasks. The top level of the redux store might look like this:
{ tasks: {...}, projects: {...} }
To configure the store, all you’d need to do is add a line of code to the root reducer:
const rootReducer = (state = {}, action) => { return { tasks: tasksReducer(state.tasks, action), projects: projectsReducer(state.projects, action), }; };
This allows each reducer to not care about the overall shape of the store, only the slice of data that it operates on.
Using a root reducer like this is so common that redux exports a combineReducers function, which accomplishes the same thing as the rootReducer function you just wrote. Once you add a few more properties to your state object, you’ll switch to the more standard combineReducers, but for now it’s worth understanding how this process works under the hood.
Next, run through the following familiar process of adding a new action by updating the relevant action, reducer, and component:
First, dispatch the new action. For dramatic effect, you’ll add a setTimeout of two seconds before you dispatch fetchTasksSucceeded, which indicates the request has completed, as shown in the following listing. Because the server responds almost instantly when running on your local machines, this delay gives you a chance to get a good feel for the loading state.
function fetchTasksStarted() { return { type: 'FETCH_TASKS_STARTED', }; } export function fetchTasks() { return dispatch => { dispatch(fetchTasksStarted()); 1 api.fetchTasks().then(resp => { setTimeout(() => { 2 dispatch(fetchTasksSucceeded(resp.data)); 2 }, 2000); 2 }); }; }
Now as part of your async action, you have two synchronous dispatches to track the request lifecycle. One indicates when the request starts, the other when it completes. Next, handle the FETCH_TASKS_STARTED action by setting the isLoading property to true in the reducer, as shown in the following listing.
... export default function tasks(state = initialState, action) { switch (action.type) { case 'FETCH_TASKS_STARTED': { return { ...state, isLoading: true, 1 }; } ... } }
Finally, you’ll update two components, App and TaskPage. In App, pass the value of isLoading to TasksPage via mapStateToProps, as shown in the following listing.
... class App extends Component { ... render() { return ( <div className="main-content"> <TasksPage tasks={this.props.tasks} onCreateTask={this.onCreateTask} onStatusChange={this.onStatusChange} isLoading={this.props.isLoading} 1 /> </div> ); } } function mapStateToProps(state) { const { tasks, isLoading } = state.tasks; 2 return { tasks, isLoading }; } ...
Finally, in TasksPage, you can check whether a request for tasks is in progress, then render a loading indicator if necessary, as shown in the following listing.
class TasksPage extends Component { ... render() { if (this.props.isLoading) { return ( <div className="tasks-loading"> Loading... 1 </div> ); } ... }
Tracking the status of a request to show a loading indicator isn’t necessarily required, but it’s become part of the expected experience in most modern web apps. This is one example of the increasingly dynamic requirements of modern web apps, but Redux was created in part to solve exactly these kinds of problems.
With network requests, there are two moments in time you care about: the request starting and the request finishing. The invoking of the fetchTasks request will always be represented by the FETCH_TASKS_STARTED action, but request completion can trigger one of two dispatches. You’ve handled only the success case so far, but proper error handling is crucial for delivering the best experience possible.
Users are never thrilled when something goes wrong, but having an operation fail and being left in the dark is much worse than seeing an error with some feedback. Taking one last look at the async action diagram in figure 4.5, the second dispatch can now fork depending on the outcome of the request.
You have plenty of ways to implement error handling in Redux. At a high level, these are the things you’ll need:
To keep things simple, you’ll dispatch a single action, FETCH_TASKS_FAILED, with the error payload. When an error is present, you’ll render the message at the top of the page, which will look something like figure 4.6.
Start from the outside-in by creating a new file for a FlashMessage component, as shown in listing 4.19. Its purpose is to accept an error message as a prop and display it in the DOM.
import React from 'react'; 1 export default function FlashMessage(props) { return ( <div className="flash-error"> {props.message} </div> ); } Error.defaultProps = { message: 'An error occurred', 2 };
In the App component, pass along the yet-to-be-created error property from the redux store in mapStateToProps, as shown in the following listing.
... import FlashMessage from './components/FlashMessage'; class App extends Component { ... render() { return ( <div className="container"> {this.props.error && <FlashMessage message={this.props.error} />} 1 <div className="main-content"> <TasksPage tasks={this.props.tasks} onCreateTask={this.onCreateTask} onStatusChange={this.onStatusChange} isLoading={this.props.isLoading} /> </div> </div> ); } } function mapStateToProps(state) { const { tasks, isLoading, error } = state.tasks; 2 return { tasks, isLoading, error }; 2 } export default connect(mapStateToProps)(App);
Because this.props.error is null for now, nothing will happen yet in the UI. You’ll need to create a new synchronous action creator, fetchTasksFailed. You already have code to handle when the request promise resolves successfully, so go ahead and add a catch block to handle when the promise is rejected.
To make testing error handling easier, manually reject a promise in the then block, so that you’re guaranteed to make it into the catch block, as shown in the following listing.
function fetchTasksFailed(error) { return { type: 'FETCH_TASKS_FAILED', payload: { error, }, }; } export function fetchTasks() { return dispatch => { dispatch(fetchTasksStarted()); api .fetchTasks() .then(resp => { // setTimeout(() => { 1 // dispatch(fetchTasksSucceeded(resp.data)); 1 // }, 2000); 1 throw new Error('Oh noes! Unable to fetch tasks!')); 2 }) .catch(err => { dispatch(fetchTasksFailed(err.message)); 3 }); }; }; ...
Finally, handle the update logic in the tasks reducer. This is a two-part change: add an error property to the initial state definition, then add a handler for the FETCH_TASKS_FAILED action. The case statement will mark the request as complete by setting isLoading to false and set the error message, as shown in the following listing.
const initialState = { tasks: [], isLoading: false, error: null, 1 }; export default function tasks(state = initialState, action) { switch (action.type) { ... case 'FETCH_TASKS_FAILED': { 2 return { 2 ...state, 2 isLoading: false, 2 error: action.payload.error, 2 }; } ... default: { return state; } } }
All said and done, it’s clear that fetching a list of tasks to render on the page is much more than making a GET request. These are the realities of modern web app development, but tools like Redux are here to help. You can handle tracking complex state to provide the best user experiences possible.
When first learning Redux, the stereotypical first tutorial is a todo app. It all seems so simple! Dispatch an action, update state in a reducer. But the question quickly turns to “How do you do anything useful?” Turns out you can’t do much in a web application without being backed by a server.
Async actions are one of the real challenges for a budding Redux user. Compared to chapter 2, where only synchronous actions are found, the complexity level in this chapter was ramped up significantly. Hopefully you now have a sense for how to properly handle asynchronous code in Redux.
By using redux-thunk, you took advantage of middleware without needing to understand what that meant. The next chapter lifts the veil on middleware and shows you all there is to see.
18.119.167.248