How to Build Simple React App (Part 3)
This is the third part of our series about building a simple react application. In this part, our main topic will be connecting our application to RESTful API. For that, we will need to use async actions, another important concept. If you haven't read the previous parts, you can find them on the following links part 1, part 2.
Async actions
To use async actions, we need to inject middleware called thunk. Thunk allows us to write async actions (action creators). As you know, until now all actions just returned a simple action object, which would be dispatched automatically. With the thunk, we get the possibility to control what and when will be dispatched, it provides us the possibility to return the function from action which can call dispatch manually. You will see in a second what that means to us. First let's add that middleware, while we are here, we will add one more middleware (redux-logger) which will log each action as it gets dispatched along with the application state before and after that action, pretty nice for debugging. First of all, install these two packages.
npm install --save redux-thunk redux-logger
And then inject them into the application.
// src/index.js
...
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk';
import logger from 'redux-logger';
...
let store = createStore(
appReducer,
applyMiddleware(logger, thunk)
);
...
So we just imported two middlewares we want to inject, and added applyMiddleware
function from redux. Inside createStore
we added the second parameter where we defined which middlewares we want to be injected (applied). Ok, now that we resolved that, let's add our first async action.
Setup RESTful API server
We don't want our todos to be defined in the initial state, on our front-end, we want them to be fetched from some external resource. Instead of writing our RESTful API here, we will use json-server. It is quite simple to set up, we will go through that process right now. First, we need to install json-server
npm install -g json-server
Then create db.json
file which will represent our database, and json-server
will create all CRUD actions over our resources defined in that file and will change that file immediately. It is a great tool for front-end testing. We will create db.json
file inside our project, just to group all stuff into one place.
// db.json
{
"todos": [
{
"id": 1,
"task": "This is simple API test task",
"done": false
},
{
"id": 2,
"task": "This is simple API test task 2",
"done": false
},
{
"id": 3,
"task": "This is simple API test task 3",
"done": true
}
]
}
This file is placed in the top folder (with package.json
and README.md
). If you take a look at this structure, you will see that it is pretty similar to one we've defined in reducer's initial state (only task texts are different). Now we will start the server. Open a new terminal tab and type:
# cd path-to-project/
json-server -p 9000 --watch db.json
You should see something like this.
And that is all, now you have all of the CRUD operations on todo
resource, which are available through localhost:9000. Now we can really write our first async action, which would be fetching all todos and putting them into our state.
First async action and fetching data from API
// src/components/Home/TodoList/actions/todoActions.js
export const fetchTodosStart = () => ({
type: types.FETCH_TODOS_START
});
export const fetchTodosError = (error: Error) => ({
type: types.FETCH_TODOS_ERROR,
error
});
export const fetchTodosSuccess = (todos: Array) => ({
type: types.FETCH_TODOS_SUCCESS,
payload: { todos }
});
export const fetchTodos = () => dispatch => {
dispatch(fetchTodosStart());
fetch(`${API_URL}/todos`)
.then((response) => response.json())
.then((body) => dispatch(fetchTodosSuccess(body)))
.catch((error) => dispatch(fetchTodosError(error)));
}
We practically created four actions (action creators), three are simple actions returning just an action object, and one is async (fetchTodos
) which dispatches the other three when it should. We could theoretically use any of these three simple actions directly, but we won't need that. fetchTodosStart
is a simple action whose purpose is just to notify the system that fetchTodos
action has started, fetchTodosError
notifies the system that some error occurred while fetching todos, and fetchTodosSuccess
notifies the system that todos are fetched and passes those fetched todos in action object.
Nothing new here, now let's take a look at fetchTodos
. The first thing to note here is that this action doesn't return a simple object but a function, with dispatch as a parameter (getState is another parameter provided by thunk, but we don't need that here, so we don't store it anywhere). At the beginning, we dispatch a signal that fetching has started. Then we do real fetch using fetch
method from the native framework. If everything goes well, we dispatch a success signal to send the response body as a value of todos parameter, and if any error (catch
part), we just dispatch an error signal providing that error as a parameter. Nothing complicated, right? That is it, we created an async action, which fetches data from the server, parses it (response.json()
part) and notifies the system on each "breakpoint". This pattern with three simple actions (as a help) will be followed by this article. It is not mandatory, you could do something like
fetch(`${API_URL}/todos`)
.then((response) => response.json())
.then((body) => dispatch({
type: types.FETCH_TODOS_SUCCESS,
payload: { todos: body }
})
.catch((error) => dispatch({
type: types.FETCH_TODOS_ERROR,
payload: { error }
});
But I find it more readable when it is separated. We haven't yet defined API_URL
constant.
// src/utils/configConstants.js
export const API_URL = 'http://localhost:9000';
And of course, we need to import that constant in todoActions.js
// src/components/Home/TodoList/actions/todoActions.js
import { API_URL } from '../../../../utils/configConstants';
Right now we are getting an error in our front-end application (Failed to compile. "export 'FETCH_TODOS_SUCCESS' (imported as 'types') was not found in '../constants'."
) That is because we haven't defined constants, and yet we use them. So let's define that.
// src/components/Home/TodoList/constants.js
export const FETCH_TODOS_START = 'FETCH_TODOS_START';
export const FETCH_TODOS_ERROR = 'FETCH_TODOS_ERROR';
export const FETCH_TODOS_SUCCESS = 'FETCH_TODOS_SUCCESS';
The next step is to add a reducer handler for this action(s), otherwise, all those signals would be useless. We will do this just by adding a new case inside todoReducer
.
case types.FETCH_TODOS_SUCCESS:
return state.set('todos', [...action.payload.todos]);
Nice and simple, just exchange state.todos with the new array containing data received from the action object. We haven't handled FETCH_TODOS_ERROR
and FETCH_TODOS_START
, currently, they are not our main focus. You could handle an error signal in some global way, or locally to your todoReducer, it depends on you, however, you want to. A start signal can be useful for something like rendering loading the bar on screen or disabling some option until the action is finished, just notice that no END
signal is sent, so you will have to handle end on success and on error. The circle is now complete, all we need to do now is to actually make use of this.
We won't need anymore that initial state defined in todoReducer (that was just test data), so let's delete it.
// src/components/Home/TodoList/reducers/todoReducer.js
...
const TodoState = new Record({
todos: []
});
...
If you look at your application now, there won't be any todos on the screen, exactly what we wanted. Now let's fetch. Where would we add this part of the code. If you remember from the last part where we talked about presentational and container components, we said that those container components should handle data fetching, so we need to change our TodoListContainer
.
// src/components/Home/TodoList/TodoListContainer.jsx
import React, { Component } from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { setTodoDone, deleteTodo, addTodo, fetchTodos } from './actions/todoActions';
import TodoList from './TodoList';
class TodoListContainer extends Component {
componentDidMount() {
this.props.fetchTodos();
}
render() {
return <TodoList {...this.props} />
}
}
const mapStateToProps = state => ({
todos: state.todoReducer.todos
});
const mapDispatchToProps = dispatch => bindActionCreators({
setTodoDone,
deleteTodo,
addTodo,
fetchTodos,
}, dispatch)
export default connect(mapStateToProps, mapDispatchToProps)(TodoListContainer);
Most parts stayed the same, we linked fetchTodos
action in our mapDispatchToProps
(and imported it at the top). But now simple connect
wrapper isn't enough for us, we need something more, something that will actually fetch data at some moment. That's why we created a new component (real TodoListContainer
) and used the lifecycle method componentDidMount
in which fetching is actually called. Its render method is just simple returning TodoList
with all received props sent down. So it is still just a wrapper, only a "smart" wrapper which does something before rendering the wrapped component. Now if you go to your browser and look at the application you should see three todos defined in our db.json
.
And our logger middleware is logging each action on our console, as you can see, only FETCH_TODOS_START
and FETCH_TODOS_SUCCESS
are logged (the first logged action you can disregard, it is just a log for fetchTodos
which doesn't actually need to be logged). If you try to add, modify or delete any todo now, it will still work as it worked before, but won't be saved to the database, that is because those actions just change reducer, neither one is actually "talking" to an external source (API), let's fix that.
Adding new todo
export const addTodoStart = () => ({
type: types.ADD_TODO_START
});
export const addTodoError = (error: Error) => ({
type: types.ADD_TODO_ERROR,
error
});
export const addTodoSuccess = (todo: Object) => ({
type: types.ADD_TODO_SUCCESS,
payload: {
todo
}
})
export const addTodo = (task: String) => dispatch => {
dispatch(addTodoStart());
fetch(`${API_URL}/todos`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
task,
done: false,
})
})
.then((response) => response.json())
.then((body) => dispatch(addTodoSuccess(body)))
.catch((error) => dispatch(addTodoError(error)));
}
We replaced addTodo
action with an async one, and also we added already familiar three methods (start, error and success actions) as helpers. The interesting thing here is that todo creating is moved from reducer into the action, actually, it is moved to API, but because of default API behavior we have to provide all parameters (can't create default value on API - which is what we would do in a real application). It is pretty much the same as fetchTodo
action, on start it dispatches start the signal, and after that, it hits the API endpoint, the only difference is that here we need to send POST
method, and set the header for Content-Type
so that the API knows how we formatted data which we send, and last but not least we need to send real data in body
as JSON encoded string. After that, we get a response, parse it as JSON, and dispatch the body as a new todo object with a success signal, or in case of an error, just dispatch an error signal with that error. Why do we dispatch the value returned from the server instead of the object we created? Simple, the server will automatically create an id
, which we need for modification and removal, so we need to wait for the server to give us a complete object, which we will then store in the reducer. Let's see reducer modifications to support this.
// old case
case types.ADD_TODO:
return state.set('todos', [ ...state.todos, { id: getLastId(state.todos) + 1, task: action.payload.task, done: false } ]);
// new case
case types.ADD_TODO_SUCCESS:
return state.set('todos', [...state.todos, action.payload.todo]);
It is actually simplified, reducer doesn't need to generate id, or an object anymore (it shouldn't generate resources anyway). That is it. Try now adding a new todo and refreshing the page, it persists.
Deleting todo
export const deleteTodoStart = () => ({
type: types.DELETE_TODO_START,
});
export const deleteTodoError = (error: Error) => ({
type: types.DELETE_TODO_ERROR,
error
});
export const deleteTodoSuccess = (id: Number) => ({
type: types.DELETE_TODO_SUCCESS,
payload: {
id
}
});
export const deleteTodo = (id: Number) => dispatch => {
dispatch(deleteTodoStart());
fetch(`${API_URL}/todos/${id}`, {
method: 'DELETE',
})
.then((response) => dispatch(deleteTodoSuccess(id)))
.catch((error) => dispatch(deleteTodoError(error)));
}
As it goes for deleteTodo
, it is pretty much the same. Helper methods (actions) are there, same as always, nothing new there, and bind-together action deleteTodo
is also the same as others, the only difference is http method, and the fact that we don't need to parse the response body (it is empty), we just need to know that response was returned successfully without error (valid status code), and we can dispatch success signal. Reducer hasn't changed at all, the only thing that changed is the name of the constant on which the handler is called, renamed from DELETE_TODO
into DELETE_TODO_SUCCESS
.
Updating todo
export const setTodoDoneStart = () => ({
type: types.SET_TODO_DONE_START
})
export const setTodoDoneError = (error: Error) => ({
type: types.SET_TODO_DONE_ERROR,
error
});
export const setTodoDoneSuccess = (id: Number, done: Boolean) => ({
type: types.SET_TODO_DONE_SUCCESS,
payload: {
id,
done
}
})
// Changed from id: Number into todo: Object to use PUT /todos/:id, avoid creating custom api routes
export const setTodoDone = (todo: Object, done: Boolean) => dispatch => {
dispatch(setTodoDoneStart());
fetch(`${API_URL}/todos/${todo.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ...todo, done })
})
.then((response) => dispatch(setTodoDoneSuccess(todo.id, done)))
.catch((error) => dispatch(setTodoDoneError(error)));
}
The same goes for setTodoDone
, everything stays the same as before. Here we use PUT
method, to use the default API update method because we are avoiding custom API routes (in a real application you would probably have a separate route only for setting done, which would get only an id
). The reducer hasn't been changed for this either (only constant name). For this we have to change a little bit call to the method (because we changed the interface, it doesn't get the only id anymore), so we need to modify it a little bit Todo
component. Inside Todo
render method we just need to change our setDone
handler, instead of () => setDone(todo.id, !todo.done)
, we want () => setDone(todo, !todo.done)
. And that is all. Now we completely migrated our application to use RESTful API for all data operations.
Conclusion
In this part, we connected our application to RESTful API and adapted all actions to actually hit API endpoints, and change data on the server. One thing you could do in a real application is to extract fetch
calls into a helper method (or a class) so that you can easily replace the library that you are using for http requests. Another thing that may be useful in real examples is normalize, it won't be discussed here but I encourage you to take a look. The next part will be the final part of this series, and it will show you the usage of selectors, also we will focus a little on application styling.