Anatomy of a React application: optimistic updates

Anatomy of a React application: optimistic updates

Anatomy of a React application: optimistic updates.

Web applications often require interaction with APIs in order to retrieve and update data. Server-side rendering can greatly reduce the time required to fetch data on the initial page load. Once the application is loaded on the browser though, additional API calls will involve further network calls which could potentially be slow and flaky, making your application sluggish. This is especially true when on mobile connections.

One solution to this problem is to perform API calls in an optimistic way: once an action requiring API interaction is triggered, we assume a positive response from the server and we dispatch the expected results before the actual API response is received. In case of an API failure (e.g. server errors or timeouts), the previously dispatched results will be reverted and the application state will be rolled-back. By doing this, the application will become more responsive and only in case of failure the user will be presented with an error and its action will be reverted.

Let’s have a look at one example by using the usual TODO list scenario. Assuming the case where a user adds a new “Learn React” TODO item and this needs to be saved in a Mongo collection, the outcome could either be:

  • Success: the add-item API call is triggered and the newly item is immediately added in the UI. When the API response comes back successfully, nothing needs updating;
  • Failure: the add-item API call is triggered and the newly item is immediately added in the UI. If the API returns an error or times-out, the application is reverted to its pre add-item state causing the item to be removed from the UI. An error should also be displayed at this point.

This technique makes your application feel very fast and responsive even with poor connectivity.

There are a number of ways optimistic updates can be achieved in a React/Redux application.

Redux-optimist

Redux-optimist is a library which you can wrap your action in a BEGIN transaction with a given ID. An API failure will then need to trigger a REVERT transaction with the same ID and that will cause the initial state changes to be reverted.

Below an example:

 
const { BEGIN } = require('redux-optimist');
import { doSomething, doSomethingFailure } from ‘./your-middleware’;
 
const id = `${action.type--${xxx}`;
const beginAction = { optimist: { type: BEGIN, id} }, action };
 
return Promise.resolve()
  .then(() => next(beginAction))
  .then(() => doSomething())
  .catch((error) => {
    const revertAction = Object.assign(
      { optimist: { type: REVERT, id: transactionId } },
      doSomethingFailure()
    );
    dispatch(revertAction);
  });

The downside of this approach is having to wrap actions as optimistic actions and to manage transaction IDs. The code is not overly complicated but when I used it in the past there seemed to be a lot of boilerplate and repetition which was was making the code less easy to read.

Redux-optmistic-ui

Admittedly, I have not used this library before and therefore I can’t comment too much on this. At first sight, this library seems similar to redux-optimistic whilst making different implementation choices. Further information can be found here.

Saga

Let’s now explore how optimistic updates can be achieved using redux-saga. The expressiveness of saga flows allows for complex chains of actions to be aggregated in a declarative manner. It should therefore be easy to dispatch an optimistic update at the beginning of a flow, perform an API call and catch a possible error which will trigger the rollback of the original state changes.

The following, also available on Github, provides an example in the context of an election vote scenario:

 
export function* vote() {
  yield put({ type: VOTE_ERROR, error: null });
  const politician = yield select(getPolitician);
  yield put({ type: VOTE_CASTED, politician });
  try {
    const apiResult = yield race({
      result: call(callVoteApi, politician),
      timeout: call(delay, 2000),
 
    });
    if (apiResult.timeout) {
      throw new Error('Unable to vote, timeout error');
    }
  } catch (error) {
    yield put({ type: VOTE_ERROR, error: error.message, politician });
  }
}

At the beginning of the vote flow, errors are reset and a politician is selected from the redux state. After that, the casted vote is immediately updated in the redux state (and therefore reflected in the UI) and the actual API call is triggered. In case of failure, the dispatch of the error will then take care of reverting the state update and display an error to the user.

The advantages of using redux-saga for optimistic updates are:

  • No need for an additional dependencies managing optimistic updates nor dispatches to be wrapped with some library specific code;
  • Expressive, declarative and flow-oriented code: all the transactions related to a workflow can be managed in the same place, making the code easier to understand and maintain.

Conclusion

Several libraries are available for performing optimistic updates in order to enhance the user experience with a more fluid and responsive UI. The only one I used in a real project so far is redux-optimistic but I would definitely be keen to experiment how an optimistic update implementation based on redux-saga would look like in one of my next projects.