Last week, I received a request from a client to make an EPOS app. The main requirement is the app must work even when internet connection is lost. So when it can’t connect to internet, the app will still store new customer and new order information into browser cache and when it can connect to internet again, it will call api to sync data changes to server. This is an interesting project so I decided to give it a try. After 15 minutes on Google, I found this library: redux-offline. From their introduction, it looks like exactly what I want for my app. So I made simple todo app with redux-offline to test out this library.
You can clone and test the example at here: https://github.com/davidtran/react-todo-offline. This repo comes with a small ExpressJS to store todos and a example React todo app.
Step to test:
- Clone and run the example.
- Disconnect your computer from internet.
- Add some todos. Your todos will be stored in localStorage and is waiting to save to server.
- Turn on internet again. redux-offline will call API to store our todos to server.
Let’s see the implementation.
In order to get started, I create a simple backend for the app. It has 3 APIs to get todo list, post new todo and remove a todo.
const express = require('express'); const app = express(); const bodyParser = require('body-parser'); const cors = require('cors'); const morgan = require('morgan'); let todos = []; let id = 99999; let port = process.env.PORT || 3000; app.use(cors()); app.use(morgan('combine')); app.get('/', (req,res) => { res.json(todos).send(); }); app.post('/', bodyParser.json(), (req, res) => { console.log(req.body); todos.push({ content: req.body.content, id: id++ }); res.json({id: id}).send(); }); app.delete('/:id', (req, res) => { let index = todos.findIndex(item => item.id === req.query.id); todos.splice(index, 1); res.sendStatus(200); }); app.listen(port, () => { console.log('App is started at port ' + port); });
Now let’s create our React app. Because other parts of our app are not important, I only show the code of main component at here:
import React, { Component } from 'react'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import { addTodo, removeTodo, clearTodo } from './redux/todo'; class App extends Component { state = { todoContent: '' } render() { let {todoContent} = this.state; let {todos, clearTodo} = this.props; return ( <div className="todo-wrapper"> <div className="todo-list"> { todos.map(todo => <div className="todo-item" key={todo.id}> <div className="todo-content"> {todo.content} </div> <div className="todo-remove-button" onClick={() => this.removeTodo(todo)}> × </div> </div> )} </div> <div className="todo-form"> <input type="text" ref={todoInput => this.todoInput = todoInput} onKeyDown={e => this.addTodo(e)} /> </div> <button onClick={clearTodo}>Clear</button> </div> ); } addTodo = (e) => { if (e.keyCode === 13) { if (!this.todoInput.value) return; this.props.addTodo(this.todoInput.value); this.todoInput.value = ''; } } removeTodo = (todo) => { this.props.removeTodo(todo.tempTodoId, todo.id); } } const mapStateToProps = state => ({ todos: state.todos.filter(item => !item.isDeleting) }); const mapDispatchToProps = dispatch => ({ addTodo: content => dispatch(addTodo(content)), removeTodo: (tempTodoId, todoId) => dispatch(removeTodo(tempTodoId, todoId)), clearTodo: () => dispatch(clearTodo()) }); export default connect(mapStateToProps, mapDispatchToProps)(App);
Because of testing purpose, I put everything in one component. This is what this component does:
- Connect to redux store to get todo list
- Display the todo list
- Call method to post new todo
- Call method to remove existing todo
Now let’s see how Redux is setup with Redux-Offline. This is the action creator for creating new todo:
export const addTodo = content => { return (dispatch, getState) => { let todoId = getState().todos.length + 1; dispatch(addTodoWithId(content, todoId)); } } export const addTodoWithId = (content, tempId) => ({ type: 'ADD_TODO', payload: { content, tempId }, meta: { offline: { effect: { url: 'http://localhost:3002', method: 'POST', body: { content } }, commit: { type: 'ADD_TODO_COMMIT', meta: {content, tempId}}, rollback: { type: 'ADD_TODO_ROLLBACK', meta: {content, tempId}} } } });
Since each todo item must have a unique key, so I create a temp Id in the addTodo method – the tempId will be replace by real ID from API later. I pass tempId to addTodoWithId. addTodoWithId is the main action which will create new todo. The redux-offline setting stay in the meta.offline property of the action object:
- effect contains the configuration object for API call. This object contains API url, API method and body content of API. By default, redux-offline uses fetch to call API, but you can configure it to use Axios or any other HTTP clients. Read more about it at here: https://github.com/jevakallio/redux-offline#configuration
- commit contains the action which will be call after API success.
- rollback contains the action that will be call when API fails ( API returns 500 status code).
This is the reducer:
export default function todo(state = [], action) { switch (action.type) { case 'ADD_TODO': return [ ...state, { id: action.payload.tempId, content: action.payload.content, isTemp: true } ]; case 'ADD_TODO_COMMIT': return state.map(item => { if (item.id === action.meta.tempId) { return { ...item, id: action.payload.id, isTemp: false } } return item; }); case 'ADD_TODO_ROLLBACK': return state.filter(item => item.id === action.payload.tempId); } }
When ADD_TODO action is dispatched, a new todo is added into Redux store. At this moment, that todo is already stored in browser cache but it is not stored in backend yet and API have not been called. So even when your app is offline, the new todo is still displayed in the app and redux-offline will wait until our app is online again to call API to store our todo. isTemp is true so we can display a spinner if we need.
When redux-offline successful call API to store todo in backend. It will dispatch ADD_TODO_COMMIT. The response from API api is stored in action.payload. At this moment, we replace the temp ID with the real ID from API. We also set isTemp to false so we can hide our spinner.
ADD_TODO_ROLLBACK is dispatched when API is failed. It means our API returns status code 500 or server doesn’t work. When network is too slow or not stable, redux-offline will try to call API few times before dispatch ADD_TODO_ROLLBACK.