Async requests with React.js and Flux, revisited.

Our company put the approach I outlined in this post into production roughly 6 weeks ago. Recently, I dove back into our Flux application, having not looked at it for a couple of weeks.

Returning to a project after a longer hiatus is - in my experience - one of the best moments to figure out the weaknesses of your codebase: You have partially mitigated the curse of knowledge by occupying your brain with other things.

I realized two things while working on our app again:

  1. Flux is awesome. I went back into most of the codebase like I have been working on it all month, something I've never experienced with any other frontend framework.

  2. Our way of doing async requests sucks. Making the Flux-Stores directly call the api layer and provide a callback is a bad idea in the long run: It's just too hard to reason about the data flow.

Stores pulling in data will screw you as soon as more than one Store needs to know about a data request. The only way to handle this is fire an action to the Dispatcher from within a Store, which starts one of those event chains. In my opinion event chains should be avoided because they are complex. It's a constant guessing and figuring out of who calls whom in what order.

So I snooped around in the new facebook flux repo and realized that they have a way better approach that is outlined in this diagramm:

diagram

Our old approach has it all backwards:

We would dispatch an action which a Store then reacts on by providing a callback to the Web Api that will eventually change the state of a Store.

The smarter way is to call the Web Api directly from an Action Creator and then make the Api dispatch an event with the request result as a payload. The Store(s) can choose to listen on those request actions and change their state accordingly.

Before I show some updated code snippets, let me explain why this is superior:

So let's walk through an example:

Let's assume we have an Entity Component:

var Entity = React.createClass({  
    getInitialState: function() {
        return EntityStore.getState();
    },
    componentDidMount: function() {
        EntityStore.addChangeListener(this._onChange);
        this.getEntityDataIfNeeded(this.props);
    },
    componentWillUnmount: function() {
        EntityStore.removeChangeListener(this._onChange);
    },
    componentWillReceiveProps: function(nextProps) {
        this.getEntityDataIfNeeded(nextProps);
    },
    getEntityDataIfNeeded: function(props) {
        var meta = EntityStore.getState().metaData;
        if(props.activeEntity && props.activeEntity !== meta.id) {

            EntityActions.getEntityData(this.props.activeEntity);

        }
    },
    _onChange: function() {this.setState(EntityStore.getState());},
    render: function() {
        // some jsx
});

This component receives props from the parent. Based on this prop (activeEntity), the component checks if it needs to request data from the server. If it determines that this is the case in the getEntityDataIfNeeded function, it will trigger an action creator.

var EntityActions = {
    getEntityData: function(entityId) {
        Api.getEntityData(entityId);
    },
}

the getEntityData Action Creator calls the Api and requests data. Note that we have not dispatched anything yet.

Here the almost complete Api module:

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

var _pendingRequests = {};


function abortPendingRequests(key) {
    if (_pendingRequests[key]) {
        _pendingRequests[key]._callback = function(){};
        _pendingRequests[key].abort();
        _pendingRequests[key] = null;
    }
}

function token() {
    return UserStore.getState().token;
}

function makeUrl(part) {
    return API_URL + part;
}

function dispatch(key, response, params) {
    var payload = {actionType: key, response: response};
    if (params) {
        payload.queryParams = params;
    }
    AppDispatcher.handleRequestAction(payload);
}

// return successful response, else return request Constants
function makeDigestFun(key, params) {
    return function (err, res) {
        if (err && err.timeout === TIMEOUT) {
            dispatch(key, Constants.request.TIMEOUT, params);
        } else if (res.status === 400) {
            UserActions.logout();
        } else if (!res.ok) {
            dispatch(key, Constants.request.ERROR, params);
        } else {
            dispatch(key, res, params);
        }
    };
}

// a get request with an authtoken param
function get(url) {
    return request
        .get(url)
        .timeout(TIMEOUT)
        .query({authtoken: token()});
}

var Api = {
    getEntityData: function(entityId) {
        var url = makeUrl("/entities/" + entityId);
        var key = Constants.api.GET_ENTITY_DATA;
        var params = {entityId: entityId};
        abortPendingRequests(key);
        dispatch(key, Constants.request.PENDING, params);
        _pendingRequests[key] = get(url).end(
            makeDigestFun(key, params)
        );
    }
};

module.exports = Api;

Let's walk through the getEntityData method line by line:

  1. assemble the url
  2. define a key that defines the type of this function
  3. store the entityId in a params variable
  4. abort any requests that might be pending
  5. dispatch an action trough the dispatcher that a request is pending
  6. make the actual request (with superagent) and define the callback when it resolves.

if you look at the makeDigestFun, you will see that depending on the result of the request, different payloads will be dispatched.

Now, multiple Stores can listen to that Constants.api.GET_ENTITY_DATA action, if they'd like to.

as an example, let's look at the EntityStore hooked up to the Entity Component from before:

var _state = {
    // your state container where 
};


var EntityStore = merge(Store, {
    getState: function() {
        return _state;
    },
});


function persistEntityData(response) {
    // do whatever you need to do with the response to store
    // the state
}


EntityStore.appDispatch = AppDispatcher.register(function(payload) {
    var action = payload.action;
    switch(action.actionType) {
        case Constants.api.GET_ENTITY_DATA:
            persistEntityData(action.response);
            break;
        default:
            return true;
    }
    EntityStore.emitChange();
    return true;
});

module.exports = EntityStore;

And this closes the Flux circle, since the emitChange() function will update the Component that requested the data (twice! first synchronously with the Constants.request.PENDING response, and then some time later again when the request resolves.

This is a very robust way of doing things, since it does not matter to the app if state is fetched asynchronously or changed synchronously. As soon as it hits the dispatcher, it is synchronous. This reduces cognitive load greatly while writing Stores, since you don't care about the origin of an action, just about their effects.

comments powered by Disqus