How to modify React TodoMVC example to use a REST API and WebSockets

This post contains:

  • Step by step guide how to modify React TodoMVC example to
    • Use Fake JSON Server as a REST API Back End to store the todo-data
    • Use WebSockets for update notifications
  • How to test Fake JSON Server endpoints with Swagger and curl

Starting point is the official Redux TodoMVC example. The modified code can be found from todomvc-fake-server repository.

Fake JSON Server

Fake JSON Server is a REST API which uses JSON flat file as a data store, so it can return predefined data from the JSON file and it updates the data to that same file. Data can be easily edited with any text editor. Fake JSON Server also has an experimental GraphQL query support, so you can compare how REST requests would turn into GraphQL queries.

Fake JSON Server can be used as a simple Back End for prototyping or maybe as a Back End for some small project, when you don’t want to use any extra setup time for the Back End.

Complete documentation and examples for Fake JSON Server can be found from Github.

Getting started

This example can be tried quickly by running Fake JSON Server from executable, from code or with Docker and by cloning modified TodoMVC repository.

1) Start Fake JSON Server

Option A: Start from the executable. This doesn’t require any pre-installed frameworks or prerequisites.

E.g. download latest version for macOS. Check the correct file for your OS from the README.

$ mkdir FakeServer && cd FakeServer
$ wget https://github.com/ttu/dotnet-fake-json-server/releases/download/0.5.0/fakeserver-osx-x64.tar.gz
$ tar -zxvf fakeserver-osx-x64.tar.gz
$ chmod +x FakeServer
$ ./FakeServer

Option B: Start from code. This requies that .NET Core is installed.

$ git clone https://github.com/ttu/dotnet-fake-json-server.git
$ cd dotnet-fake-json-server/FakeServer
$ dotnet run --file tododb.json --urls http://localhost:57602

Option C: Start with Docker. This requires that Docker is installed.

$ git clone https://github.com/ttu/dotnet-fake-json-server.git
$ cd dotnet-fake-json-server
$ docker build -t fakeapi .
$ docker run -it -p 57602:57602 fakeapi
2) Start the modified Redux TodoMVC example
$ git clone https://github.com/ttu/todomvc-fake-server.git
$ cd todomvc-fake-server
$ npm install
$ npm start

Redux recap

To follow this example it is good to understand the basics of Redux.

Redux works the same way as all other frameworks, by magic. Just write boilerplate and everything connects and works “automatically”.

Redux data flow diagram. Credit to jenyaterpil.

  • Create store, pass reducers and middlware to it (index.js)
  • Containers and components will get the correct store through Provider (index.js)
  • Reducers handle correct functionality identified by action constants (reducers/todos.js)
  • Container maps the needed parts of state and binds actions to props, so basically it connects React components to the Redux store (containers/App.js)
    • Actions are called through dispatch and this is the only way to trigger state change. bindActionCreators binds actions to dispatch for convenience.
  • Components get needed props from the parent, and is updated only when the state is changes (components/MainSection.js)

How to get the original version of Redux TodoMVC to use Fake JSON Server.

By default the TodoMVC example uses only internal state to keep up with changes. We need to change the actions completely as data will be fetched and updated to the server. Also smaller updates are needed to other parts of app.

Some people have a preference to use some library for fetching and posting data to the server, although Fetch API is pretty good. Superagent is one of the many 3rd party libraries that handle the job well and it is used in the modified version.

$ npm install superagent

Old code is not removed, but only commented out, so comparing the new and the original version is easier.

Original code doesn’t have semicolons. I think that is a blasphemy, but having 2 separate styles in the same codebase is also a blasphemy, so I didn’t use semicolons either :sadpanda:

Middleware

Requests to the server are asynchronous so custom middleware is required for async actions. In this example redux-thunk is used as a middleware. Thunk is not as sexy as some other middlewares, but it is simple and it works well for this example.

$ npm install redux-thunk

index.js: Import redux-thunk and apply it to createStore.

import thunk from 'redux-thunk'

// const store = createStore(reducer)
const store = createStore(reducer, applyMiddleware(thunk))

Functionality: Get TODOs

constants/ActionTypes.js: Add new constant. New constants are needed, so completed action can be matched with correct reducer.

export const GET_TODOS = 'GET_TODOS'

actions/index.js: Import superagent and add the Back End url.

import * as types from '../constants/ActionTypes'
import superagent from 'superagent'

const BASE_URL = 'http://localhost:57602/api/todos/'

actions/index.js: Add getTodos action creator function. It requests data from http://localhost:57602/api/todos/ and when data is received, it dispatches action payload with type GET_TODOS to the reducer.

export const getTodos = () => { return dispatch => {
    return superagent
        .get(`${BASE_URL}`)
        .end((err, res) => {
            if (err)
                dispatch({ type: types.GET_TODOS, data: [] })
            else
                dispatch({ type: types.GET_TODOS, data: res.body })
        })
}}

reducers/todos.js: Add payload handling for the type GET_TODOS. Initial state is not needed anymore, as data is loaded from the server. GET_TODOS sets received data as state.

import { ADD_TODO, DELETE_TODO, EDIT_TODO, COMPLETE_TODO, COMPLETE_ALL, CLEAR_COMPLETED, GET_TODOS } from '../constants/ActionTypes'

// Initial state is not needed anymore
const initialState = [
  // {
  //   text: 'Use Redux',
  //   completed: false,
  //   id: 0
  // }
]

export default function todos(state = initialState, action) {
  switch (action.type) {
    // Now as todos are stored to server we need to update whole state
    case GET_TODOS:
      return [ ...action.data ]
    ...

components/MainSection.js: Add componentDidMount function where initial state will be loaded. Also handling for reload on WebSocket onmessage is handled here. WebSocket gets a new message every time an item is created, updated or deleted.

componentDidMount() {
   this.props.actions.getTodos()

   this.connection = new WebSocket('ws://localhost:57602/ws')

   this.connection.onmessage = evt => {
     this.props.actions.getTodos()
   }
}

Now when you start your app, data is loaded from the Back End.

Functionality: Add, Delete and Edit TODO

actions/index.js: addTodo, deleteTodo and editTodo definitions stay the same as in original. Just send the data to the Back End and handle the result when it arrives.

// export const addTodo = text => ({ type: types.ADD_TODO, text })
export const addTodo = text => { return dispatch => {
    return superagent
        .post(`${BASE_URL}`)
        .send({ text: text, completed: false })
        .end((err, res) => dispatch({ type: types.ADD_TODO, id: res.body.id, text: text, completed: false }))
}}

// export const deleteTodo = id => ({ type: types.DELETE_TODO, id })
export const deleteTodo = id => { return dispatch => {
    return superagent
        .delete(`${BASE_URL}${id}`)
        .end((err, res) => dispatch({ type: types.DELETE_TODO, id }))
}}

// export const editTodo = (id, text) => ({ type: types.EDIT_TODO, id, text })
export const editTodo = (id, text) => { return dispatch => {
    return superagent
        .patch(`${BASE_URL}${id}`)
        .send({ text: text })
        .end((err, res) => dispatch({ type: types.EDIT_TODO, id: id, text: text }))
}}

reducers/todos.js: Original version of ADD_TODO case calculated id from current items, but in this version the correct id comes from the Server. Cases for DELETE_TODO and EDIT_TODO stay the same as in the original file.

case ADD_TODO:
   return [
     {
       // Id will come with payload          
       // id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
       id: action.id,
       completed: action.completed,
       text: action.text
     },
     ...state
   ]

case DELETE_TODO:
   return state.filter(todo =>
     todo.id !== action.id
   )

case EDIT_TODO:
   return state.map(todo =>
     todo.id === action.id ?
       { ...todo, text: action.text } :
       todo
   )

As definitions of the actions stay same as in the original, there is no need to update Components.

Functionality: Complete TODO

actions/index.js: In the orignial version clicking complete just toggled the completed state, but now we need to pass the correct state to the Back End.

// export const completeTodo = id => ({ type: types.COMPLETE_TODO, id })
export const completeTodo = (id, state) => { return dispatch => {
    return superagent
        .patch(`${BASE_URL}${id}`)
        .send({ completed: state })
        .end((err, res) => dispatch({ type: types.COMPLETE_TODO, id: id, completed: state }))
}}

reducers/todos.js: Toggling was done originally in the reducer. Now COMPLETE_TODO sets the completed state from the action.

case COMPLETE_TODO:
   return state.map(todo =>
     todo.id === action.id ?
       // No more toggling, completed state comes with payload
       // { ...todo, completed: !todo.completed } :
       { ...todo, completed: action.completed } :          
       todo
   )

components/MainSection.js: As COMPLETE_TODO doesn’t just toggle the completed state in the reducer, we need to pass the correct value to the action. This is done by toggling the current value in the component and passing the value with item’s id to the action.

<input className="toggle"
                 type="checkbox"
                 checked={todo.completed}
                 // onChange={() => completeTodo(todo.id)} />
                 onChange={() => completeTodo(todo.id, !todo.completed)} />

Functionality: Complete All and Clear Completed

actions/index.js: As Fake Server is an extremely general REST API we need to collect the id’s in the Front End and do multiple updates.

// export const completeAll = () => ({ type: types.COMPLETE_ALL })
export const completeAll = ids => { return dispatch => {
    var promises = ids.map(id => {
        return new Promise((resolve, reject) => {
            superagent
                .patch(`${BASE_URL}${id}`)
                .send({ completed: true })
                .end((err, res) => resolve())
        })
    })
    Promise.all(promises).then(results => dispatch(({ type: types.COMPLETE_ALL })))
}}

// export const clearCompleted = () => ({ type: types.CLEAR_COMPLETED })
export const clearCompleted = ids => { return dispatch => {
    var promises = ids.map(id => {
        return new Promise((resolve, reject) => {
            superagent
                .delete(`${BASE_URL}${id}`)
                .end((err, res) => resolve())
        })
    })
    Promise.all(promises).then(results => dispatch(({ type: types.CLEAR_COMPLETED })))
}}

In real life I would add own endpoints for completeAll and clearCompleted to the Back End. Using RPC-like endpoints is a good solution when you like to keep most of the functionality at the Back End. For example:

[HttpPost("completeAll")]
public async Task<IActionResult> CompleteAll()
{
    await _ds.GetCollection("todo").UpdateManyAsync(e => true, new { completed = true });
    return NoContent();
}

[HttpPost("removeCompleted")]
public async Task<IActionResult> RemoveCompleted()
{
    await _ds.GetCollection("todo").DeleteManyAsync(e => e.completed);
    return NoContent();
}

reducers/todos.js: Reducer doesn’t need any modifications for COMPLETE_ALL and CLEAR_COMPLETED.

 case COMPLETE_ALL:
   const areAllMarked = state.every(todo => todo.completed)
   return state.map(todo => ({
     ...todo,
     completed: !areAllMarked
   }))

 case CLEAR_COMPLETED:
   return state.filter(todo => todo.completed === false)

 default:
   return state
}

components/MainSection.js: As the new version of clearCompleted takes a list of todo item ids, we need to pass those as arguments.

// handleClearCompleted = () => {
handleClearCompleted = (ids) => {
  // this.props.actions.clearCompleted()
  this.props.actions.clearCompleted(ids)
}
  
  ....
  
renderFooter(completedCount) {
  const { todos } = this.props
  const { filter } = this.state
  const activeCount = todos.length - completedCount

  if (todos.length) {
    return (
      <Footer completedCount={completedCount}
              activeCount={activeCount}
              filter={filter}
              //onClearCompleted={this.handleClearCompleted}                
              onClearCompleted={() => this.handleClearCompleted(todos.filter(e => e.completed).map(e => e.id))}
              onShow={this.handleShow} />
    )
  }
}

completeAll also takes list of ids as arguments.

renderToggleAll(completedCount) {
    const { todos, actions } = this.props
    if (todos.length > 0) {
      return (
        <input className="toggle-all"
               type="checkbox"
               checked={completedCount === todos.length}
               //onChange={actions.completeAll} />               
               onChange={() => actions.completeAll(todos.filter(e => e.completed === false).map(e => e.id))} />
      )
    }
  }

For some reason clearCompleted and completeAll had different handling for onChange in the original example. Maybe reason behind this was to show that you can have own function or just use props straight.

Now you are good to go! Open two browser tabs side by side (http://localhost:3000) and see the updates immediately on both pages thanks to WebSockets!

Stored JSON will look like this:

{
  "todos": [
    {
      "text": "Watch more television",
      "completed": false,
      "id": 0
    },
    {
      "text": "Buy new pillow",
      "completed": true,
      "id": 1
    }
  ]
}

Test API endpoints with Swagger and curl

The best way to edit your data is of course manually. Open the JSON file with any editor and save your changes.

NOTE: By default data store will reload data from the JSON with every request. For performance reasons this can be changed to a mode where queries won’t reload data.

You can test requests with Swagger: http://localhost:57602/swagger, curl, Postman etc.

Get items

Open Swagger

  1. Write to colletionId: todos
  2. Press Try it out!

curl

With curl you can also get items with queries. e.g. get completed Todo items.

$ curl http://localhost:57602/api/todos?completed=True

GraphQL

Get completed Todo items with GraphQL query:

query { 
  todos(completed: true) { 
    id 
    text 
    completed 
  } 
}
$ curl -H "Content-type: application/graphql" -X POST -d 'query { todos(completed: true) { id text completed } }' http://localhost:57602/graphql

Create new items

Open Swagger

  1. Write to collectionId: todos
  2. Write to item: { text: 'New item from Swagger', completed: false }
  3. Press Try it out!

curl

$ curl -H "Accept: application/json" -H "Content-type: application/json" -X POST -d '{ "text": "New item from curl", "completed": false }' http://localhost:57602/api/todos/

Update items

Update the item state to completed. This can be done by replacing the whole item with PUT or by providing only the updated attributes with PATCH.

Set Todo item with id 1 to completed with PATCH.

Open Swagger

  1. Write to collectionId: todos
  2. Write to id: 1
  3. Write to item: { completed: true }
  4. Press Try it out!

curl

$ curl -H "Accept: application/json" -H "Content-type: application/json" -X PATCH -d '{ "completed": true }' http://localhost:57602/api/todos/1

Async jobs

Fake JSON Server can simulate long running jobs. To create a new Todo item with a long running job, use the async endpoint instead of the api endpoint.

Open Swagger

  1. Write to collectionId: todos
  2. Write to item: { text: 'New async item from Swagger', completed: false }
  3. Press Try it out!

curl

$ curl -H "Accept: application/json" -H "Content-type: application/json" -X POST -d '{ "text": "Async job", "completed": false }' http://localhost:57602/async/todos/

By default Fake JSON Server has a 10 second delay as a long running job simulation time, so after 10 seconds, a new item will appear to the todo list.

Headers has a link to the queue item. If task is not finished, response will return 200 OK and when task is finished return value is 303 SeeOther. Finished response has a link to the new item in the Location header.

If you request the queue item with Swagger and the job is finished, it will return the new item as Swagger uses auto redirect.

Curl will show all headers with vebose argument.

Final words

There are many uses cases for Fake JSON Server, this article shows how to use it as an IoT backend. Fake JSON Server is used to collect data from sensors for validation and to provide a way to observe sensor statuses real-time.

Fake JSON Server has more features than were not covered in this post. Check the complete guide and more examples from https://github.com/ttu/dotnet-fake-json-server.

Happy coding!

Written on September 20, 2017