Async requests with React.js and Flux

UPDATE: the methodology of this post is not optimal, read here for a better approach.

More and more examples surface on how to use React. The Flux pattern is very important for React, since it enables you to write complex applications without a big framework solution. This is attractive because you can tailor the implementation of the pattern to suit your specific type of app. On the flip side, you don't get finished a solution for every problem out of the box, which leaves people wondering on how to do certain things.

I'd like to share some snippets on how we handle asynchronous requests in our React app. Most React examples/tutorials exclude this to keep it simple, but it is a tricky subject so I'll try to give you some ideas on how to tackle the problem.

What this example takes care of

Implementation

We chose to encapsulate all asynchronous requests we need to make into a module and expose it via functions to the Flux Stores that trigger the requests. An example of the usage of this module within a Flux Store looks like this:

var Api = require('./Api');  // this is what we're discussing here
var EntityStore

_entityData = null; //the flux way on storing data

// the flux way of making a change to data
function updateEntityData(data) {
    _entityData = data;
    // tell the Component that this store changed
    EntityStore.emitChange();
}

function activateEntity (id) {
    if (id) {
        // get the data of an entity and provide the callback
        // that manipulates the entityData state.
        Api.getEntityData(id, function(res) {
            if (ASYNC.isAny(res)) {
                updateEntityData(res);
            } else {
                updateEntityData(res.entity);
            }
        });
    } else {
        updateEntityData(null);
    }
}

// Entity Store ommited in the snippet

Note the if statement checking for the result being async state. This callback gets called multiple times over the lifetime of a request and will not only write the data to the store, but also the state of the async request, so that the state can be rendered (loading icon, error message on failure...).

This type of function could also be used directly on a Component by implementing updateEntityData with this.setState(...).

Below you see an overview over the internals of that Api module:

var request = require('superagent'); // (1)
var ASYNC = require('./constants/ASYNC'); // (2)
var AUTH = require('./constants/AUTH');
var CompositionActions = require('./actions/CompositionActions');

var API_URL = '/api/v2';
var TIMEOUT = 10000;
var Api;

var _token = null; // (3)
var _pendingRequests = {};  // (4)

// other needed functions ommitted here

Api = {
    login: function(username, password, remember) {
        // login stuff
    },
    logout: function() {
        // logout stuff
    },
    getEntityData: function(entityId, callback) {
        // the getEntityData function used above
    }
};

module.exports = Api;

Some things to note here:

  1. We use superagent instead of jQuery, the api is much nicer.
  2. We have some constants objects (ASYNC and AUTH) that represent different states that are not data, but need to be rendered.
  3. Authentication means state. In a token-based auth-system, you need to manage the token in memory.
  4. If you want to cancel some requests if another one is fired, you need to store the requests.

Let's look at the getEntityData function before I explain login and logout, so just assume that _token references a valid token.

some general superagent setup can be encapsulated:

function get(url) {
    return request
        .get(url)
        .timeout(TIMEOUT)
        //yes, query param, for demonstration purposes
        .query({authtoken: _token});
}

with this, getEntityData turns into a very short function that does the job:

Api = {
    // login / logout functions omitted
    getEntityData: function(entityId, callback) {
        var url = makeUrl("/entities/" + entityId); // (1)
        var key = 'get-entity-data';  // (2)
        startRequest(key, callback);  // (3)
        _pendingRequests[key] = get(url).end(makeDigestFun(callback));  // (4)
    },
};
  1. makeUrl just prepends the API_URL constant to the string
  2. the key is used to uniquely identify the type of the request for potential aborting
  3. startRequest right away persists a ASYNC.PENDING state to the store, and aborts a potential existing pending request with the same key
  4. since the callback expects ASYNC constants if the response does not contain the actual data, we need the makeDigestFunction to assure the translation from the response state to ASYNC constants.

Here the functions used in getEntityData:

function abortPreviousRequests(key) {
    if (_pendingRequests[key]) {
        // unlink the callback first!
        _pendingRequests[key]._callback = function(){};
        _pendingRequests[key].abort();
        _pendingRequests[key] = null;
    }
}


function startRequest(key, callback) {
    abortPreviousRequests(key);
    callback(ASYNC.PENDING);
}

function makeDigestFun(callback) {
    return function (err, res) {
        if (err && err.timeout === TIMEOUT) {
            callback(ASYNC.TIMEOUT);
        } else if (res.status === 400) {
            Api.logout();  // if authentication fails, we log out
        } else if (!res.ok) {
            callback(ASYNC.ERROR);
        } else {
            callback(res.body);
        }
    };
}

Until now, the assumption was a valid authtoken. But how do we get that token? Here three things you need to be aware of:

Here are the login/logout functions that can be used to get/destroy an authentication token:

Api = {
    login: function(username, password, remember) {
        var url = makeUrl('/authtoken');
        abortPendingRequests(url);
        _pendingRequests[url] = request
            .post(url)
            // basic auth here to get a token
            .auth(username, password)
            .timeout(TIMEOUT)
            .end(makeLoginDigestFun(remember));
    },
    logout: function() {
        setAuthStatus(AUTH.NOT_AUTHENTICATED);
    },
}


function makeLoginDigestFun(remember) {
    return function (err, res) {
        if (!res.ok) {
            setAuthStatus(AUTH.FAILED);
        } else {
            setToken(res.body.token, remember);
            setAuthStatus(AUTH.AUTHENTICATED);
        }
    };
}

function setToken(token, remember) {
    _token = token;
    // store the token in Storage
    if (remember) {
        localStorage.setItem(TOKEN_STORAGE_KEY, token);
    } else {
        sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
    }
}

function setAuthStatus(status) {
    // remove the token from system if you are not authenticated
    if (status !== AUTH.AUTHENTICATED) {
        localStorage.removeItem(TOKEN_STORAGE_KEY);
        sessionStorage.removeItem(TOKEN_STORAGE_KEY);
        _token = null;
    }
    // push the auth change to the view, so it can be rendered
    CompositionActions.setAuthStatus(status);
}

to close the circle, instead of doing

...
var TIMEOUT = 10000;
var Api;

_token = null;

...

in the second snippet (the overview of the Api module) of this post, you do the below to load a token from storage if it exists.

....
var TOKEN_STORAGE_KEY = 'concatAuthToken';
var Api;

function getTokenFromStorage() {
    var token = localStorage.getItem(TOKEN_STORAGE_KEY);
    if (!token) {
        token = sessionStorage.getItem(TOKEN_STORAGE_KEY);
    }
    if (token) {
        CompositionActions.setAuthStatus(AUTH.AUTHENTICATED);
    }
    return token;
}

var _token = getTokenFromStorage();

...

Closing Remarks

You could enhance this solution in multiple ways: For example return Promises to manage dependencies between api calls or then make a more sophisticated and secure authentication system.

This example translates nicely into a general way of solving a specific problem in React: First, think about the different states you want to render. Once you have an overview over the states, think about where their changes origin from. You need to assess if the Flux-Store (or the component if you don't use Flux) can pull the state in, or something needs to push the state into the Store. In case of a pull, the callback is often an intuitive way to go. If you need to push, I found events to be more favorable.

comments powered by Disqus