It's my implementation using facebook Flux and Immutable.js that I think responds to many of your concerns, based on few rules of thumb :
STORES
- Stores are responsible for maintaining data state through Immutable.Record and maintaining cache through a global Immutable.OrderedMap referencing Recordinstance viaids.
- Stores directly call WebAPIUtilsfor read operations and triggeractionsfor write operations.
- Relationship between RecordAandFooRecordBare resolved from aRecordAinstance through afoo_idparams and retrieved via a call such asFooStore.get(this.foo_id)
- Stores only expose gettersmethods such asget(id),getAll(), etc.
APIUTILS
- I use SuperAgent for ajax calls. Each request is wrapped in Promise
- I use a map of read request Promiseindexed by the hash of url + params
- I trigger action through ActionCreators such as fooReceived or fooError when Promiseis resolved or rejected.
- fooErroraction should certainly contains payloads with validation errors returned by the server.
COMPONENTS
- The controller-view component listen for changes in store(s).
- All my components, other than controller-view component, are 'pure', so I use ImmutableRenderMixin to only re-render what it's really needed (meaning that if you print Perf.printWastedtime, it should be very low, few ms.
- Since Relay and GraphQL are not yet open sourced, I enforce to keep my component propsas explicit as possible viapropsType.
- Parent component should only passes down the necessary props. If my parent component holds an object such as var fooRecord = { foo:1, bar: 2, baz: 3};(I'm not usingImmutable.Recordhere for the sake of simplicity of this example) and my child component need to displayfooRecord.fooandfooRecord.bar, I do not pass the entirefooobject but onlyfooRecordFooandfooRecordBaras props to my child component because an other component could edit thefoo.bazvalue, making the child component re-render while this component doesn't need at all this value !
ROUTING
- I simply use ReactRouter
IMPLEMENTATION
Here is a basic example :
api
apiUtils/Request.js
var request = require('superagent');
//based on http://stackoverflow.com/a/7616484/1836434
var hashUrl = function(url, params) {
    var string = url + JSON.stringify(params);
    var hash = 0, i, chr, len;
    if (string.length == 0) return hash;
    for (i = 0, len = string.length; i < len; i++) {
        chr   = string.charCodeAt(i);
        hash  = ((hash << 5) - hash) + chr;
        hash |= 0; // Convert to 32bit integer
    }
    return hash;
}
var _promises = {};
module.exports = {
    get: function(url, params) {
        var params = params || {};
        var hash = hashUrl(url, params);
        var promise = _promises[hash];
        if (promise == undefined) {
            promise = new Promise(function(resolve, reject) {
                request.get(url).query(params).end( function(err, res) {
                    if (err) {
                        reject(err);
                    } else {
                        resolve(res);
                    }
                });
            });
            _promises[hash] = promise;
        }
        return promise;
    },
    post: function(url, data) {
        return new Promise(function(resolve, reject) {
            var req = request
                .post(url)
                .send(data)
                .end( function(err, res) {
                    if (err) {
                        reject(err);
                    } else {
                        resolve(res);
                    }
                });
        });
    }
};
apiUtils/FooAPI.js
var Request = require('./Request');
var FooActionCreators = require('../actions/FooActionCreators');
var _endpoint = 'http://localhost:8888/api/foos/';
module.exports = {
    getAll: function() {
        FooActionCreators.receiveAllPending();
        Request.get(_endpoint).then( function(res) {
            FooActionCreators.receiveAllSuccess(res.body);
        }).catch( function(err) {
            FooActionCreators.receiveAllError(err);
        });
    },
    get: function(id) {
        FooActionCreators.receivePending();
        Request.get(_endpoint + id+'/').then( function(res) {
            FooActionCreators.receiveSuccess(res.body);
        }).catch( function(err) {
            FooActionCreators.receiveError(err);
        });
    },
    post: function(fooData) {
        FooActionCreators.savePending();
        Request.post(_endpoint, fooData).then (function(res) {
            if (res.badRequest) { //i.e response return code 400 due to validation errors for example
                FooActionCreators.saveInvalidated(res.body);
            }
            FooActionCreators.saved(res.body);
        }).catch( function(err) { //server errors
            FooActionCreators.savedError(err);
        });
    }
    //others foos relative endpoints helper methods...
};
stores
stores/BarStore.js
var assign = require('object-assign');
var EventEmitter = require('events').EventEmitter;
var Immutable = require('immutable');
var AppDispatcher = require('../dispatcher/AppDispatcher');
var ActionTypes = require('../constants/BarConstants').ActionTypes;
var BarAPI = require('../APIUtils/BarAPI')
var CHANGE_EVENT = 'change';
var _bars = Immutable.OrderedMap();
class Bar extends Immutable.Record({
    'id': undefined,
    'name': undefined,
    'description': undefined,
}) {
    isReady() {
        return this.id != undefined //usefull to know if we can display a spinner when the Bar is loading or the Bar's data if it is ready.
    }
    getBar() {
        return BarStore.get(this.bar_id);
    }
}
function _rehydrate(barId, field, value) {
    //Since _bars is an Immutable, we need to return the new Immutable map. Immutable.js is smart, if we update with the save values, the same reference is returned.
    _bars = _bars.updateIn([barId, field], function() {
        return value;
    });
}
var BarStore = assign({}, EventEmitter.prototype, {
    get: function(id) {
        if (!_bars.has(id)) {
            BarAPI.get(id);
            return new Bar(); //we return an empty Bar record for consistency
        }
        return _bars.get(id)
    },
    getAll: function() {
        return _bars.toList() //we want to get rid of keys and just keep the values
    },
    Bar: Bar,
    emitChange: function() {
        this.emit(CHANGE_EVENT);
    },
    addChangeListener: function(callback) {
        this.on(CHANGE_EVENT, callback);
    },
    removeChangeListener: function(callback) {
        this.removeListener(CHANGE_EVENT, callback);
    },
});
var _setBar = function(barData) {
    _bars = _bars.set(barData.id, new Bar(barData));
};
var _setBars = function(barList) {
    barList.forEach(function (barData) {
        _setbar(barData);
    });
};
BarStore.dispatchToken = AppDispatcher.register(function(action) {
    switch (action.type)
    {   
        case ActionTypes.BAR_LIST_RECEIVED_SUCESS:
            _setBars(action.barList);
            BarStore.emitChange();
            break;
        case ActionTypes.BAR_RECEIVED_SUCCESS:
            _setBar(action.bar);
            BarStore.emitChange();
            break;
        case ActionTypes.BAR_REHYDRATED:
            _rehydrate(
                action.barId,
                action.field,
                action.value
            );
            BarStore.emitChange();
            break;
    }
});
module.exports = BarStore;
stores/FooStore.js
var assign = require('object-assign');
var EventEmitter = require('events').EventEmitter;
var Immutable = require('immutable');
var AppDispatcher = require('../dispatcher/AppDispatcher');
var ActionTypes = require('../constants/FooConstants').ActionTypes;
var BarStore = require('./BarStore');
var FooAPI = require('../APIUtils/FooAPI')
var CHANGE_EVENT = 'change';
var _foos = Immutable.OrderedMap();
class Foo extends Immutable.Record({
    'id': undefined,
    'bar_id': undefined, //relation to Bar record
    'baz': undefined,
}) {
    isReady() {
        return this.id != undefined;
    }
    getBar() {
        // The whole point to store an id reference to Bar
        // is to delegate the Bar retrieval to the BarStore,
        // if the BarStore does not have this Bar object in
        // its cache, the BarStore will trigger a GET request
        return BarStore.get(this.bar_id); 
    }
}
function _rehydrate(fooId, field, value) {
    _foos = _foos.updateIn([voucherId, field], function() {
        return value;
    });
}
var _setFoo = function(fooData) {
    _foos = _foos.set(fooData.id, new Foo(fooData));
};
var _setFoos = function(fooList) {
    fooList.forEach(function (foo) {
        _setFoo(foo);
    });
};
var FooStore = assign({}, EventEmitter.prototype, {
    get: function(id) {
        if (!_foos.has(id)) {
            FooAPI.get(id);
            return new Foo();
        }
        return _foos.get(id)
    },
    getAll: function() {
        if (_foos.size == 0) {
            FooAPI.getAll();
        }
        return _foos.toList()
    },
    Foo: Foo,
    emitChange: function() {
        this.emit(CHANGE_EVENT);
    },
    addChangeListener: function(callback) {
        this.on(CHANGE_EVENT, callback);
    },
    removeChangeListener: function(callback) {
        this.removeListener(CHANGE_EVENT, callback);
    },
});
FooStore.dispatchToken = AppDispatcher.register(function(action) {
    switch (action.type)
    {
        case ActionTypes.FOO_LIST_RECEIVED_SUCCESS:
            _setFoos(action.fooList);
            FooStore.emitChange();
            break;
        case ActionTypes.FOO_RECEIVED_SUCCESS:
            _setFoo(action.foo);
            FooStore.emitChange();
            break;
        case ActionTypes.FOO_REHYDRATED:
            _rehydrate(
                action.fooId,
                action.field,
                action.value
            );
            FooStore.emitChange();
            break;
    }
});
module.exports = FooStore;
components
components/BarList.react.js (controller-view component)
var React = require('react/addons');
var Immutable = require('immutable');
var BarListItem = require('./BarListItem.react');
var BarStore = require('../stores/BarStore');
function getStateFromStore() {
    return {
        barList: BarStore.getAll(),
    };
}
module.exports = React.createClass({
    getInitialState: function() {
        return getStateFromStore();
    },
    componentDidMount: function() {
        BarStore.addChangeListener(this._onChange);
    },
    componentWillUnmount: function() {
        BarStore.removeChangeListener(this._onChange);
    },
    render: function() {
        var barItems = this.state.barList.toJS().map(function (bar) {
            // We could pass the entire Bar object here
            // but I tend to keep the component not tightly coupled
            // with store data, the BarItem can be seen as a standalone
            // component that only need specific data
            return <BarItem
                        key={bar.get('id')}
                        id={bar.get('id')}
                        name={bar.get('name')}
                        description={bar.get('description')}/>
        });
        if (barItems.length == 0) {
            return (
                <p>Loading...</p>
            )
        }
        return (
            <div>
                {barItems}
            </div>
        )
    },
    _onChange: function() {
        this.setState(getStateFromStore();
    }
});
components/BarListItem.react.js
var React = require('react/addons');
var ImmutableRenderMixin = require('react-immutable-render-mixin')
var Immutable = require('immutable');
module.exports = React.createClass({
    mixins: [ImmutableRenderMixin],
    // I use propTypes to explicitly telling
    // what data this component need. This 
    // component is a standalone component
    // and we could have passed an entire
    // object such as {id: ..., name, ..., description, ...}
    // since we use all the datas (and when we use all the data it's
    // a better approach since we don't want to write dozens of propTypes)
    // but let's do that for the example's sake 
    propTypes: {
        id: React.PropTypes.number.isRequired,
        name: React.PropTypes.string.isRequired,
        description: React.PropTypes.string.isRequired
    }
    render: function() {
        return (
            <li>
                <p>{this.props.id}</p>
                <p>{this.props.name}</p>
                <p>{this.props.description}</p>
            </li>
        )
    }
});
components/BarDetail.react.js
var React = require('react/addons');
var ImmutableRenderMixin = require('react-immutable-render-mixin')
var Immutable = require('immutable');
var BarActionCreators = require('../actions/BarActionCreators');
module.exports = React.createClass({
    mixins: [ImmutableRenderMixin],
    propTypes: {
        id: React.PropTypes.number.isRequired,
        name: React.PropTypes.string.isRequired,
        description: React.PropTypes.string.isRequired
    },
    handleSubmit: function(event) {
        //Since we keep the Bar data up to date with user input
        //we can simply save the actual object in Store.
        //If the user goes back without saving, we could display a 
        //"Warning : item not saved" 
        BarActionCreators.save(this.props.id);
    },
    handleChange: function(event) {
        BarActionCreators.rehydrate(
            this.props.id,
            event.target.name, //the field we want to rehydrate
            event.target.value //the updated value
        );
    },
    render: function() {
        return (
            <form onSubmit={this.handleSumit}>
                <input
                    type="text"
                    name="name"
                    value={this.props.name}
                    onChange={this.handleChange}/>
                <textarea
                    name="description"
                    value={this.props.description}
                    onChange={this.handleChange}/>
                <input
                    type="submit"
                    defaultValue="Submit"/>
            </form>
        )
    },
});
components/FooList.react.js (controller-view component)
var React = require('react/addons');
var FooStore = require('../stores/FooStore');
var BarStore = require('../stores/BarStore');
function getStateFromStore() {
    return {
        fooList: FooStore.getAll(),
    };
}
module.exports = React.createClass({
    getInitialState: function() {
        return getStateFromStore();
    },
    componentDidMount: function() {
        FooStore.addChangeListener(this._onChange);
        BarStore.addChangeListener(this._onChange);
    },
    componentWillUnmount: function() {
        FooStore.removeChangeListener(this._onChange);
        BarStore.removeChangeListener(this._onChange);
    },
    render: function() {
        if (this.state.fooList.size == 0) {
            return <p>Loading...</p>
        }
        return this.state.fooList.toJS().map(function (foo) {
            <FooListItem 
                fooId={foo.get('id')}
                fooBar={foo.getBar()}
                fooBaz={foo.get('baz')}/>
        });
    },
    _onChange: function() {
        this.setState(getStateFromStore();
    }
});
components/FooListItem.react.js
var React = require('react/addons');
var ImmutableRenderMixin = require('react-immutable-render-mixin')
var Bar = require('../stores/BarStore').Bar;
module.exports = React.createClass({
    mixins: [ImmutableRenderMixin],
    propTypes: {
        fooId: React.PropTypes.number.isRequired,
        fooBar: React.PropTypes.instanceOf(Bar).isRequired,
        fooBaz: React.PropTypes.string.isRequired
    }
    render: function() {
        //we could (should) use a component here but this answer is already too long...
        var bar = <p>Loading...</p>;
        if (bar.isReady()) {
            bar = (
                <div>
                    <p>{bar.get('name')}</p>
                    <p>{bar.get('description')}</p>
                </div>
            );
        }
        return (
            <div>
                <p>{this.props.fooId}</p>
                <p>{this.props.fooBaz}</p>
                {bar}
            </div>
        )
    },
});
Let's go through an entire loop for FooList:
State 1:
- User hits the page /foos/ listing the Foos via the FooListcontroller-view component
- FooListcontroller-view component calls- FooStore.getAll()
- _foosmap is empty in- FooStoreso- FooStoreperforms a request via- FooAPI.getAll()
- The FooListcontroller-view component renders itself as loading state since itsstate.fooList.size == 0.
Here's the actual look of our list :
++++++++++++++++++++++++
+                      +
+     "loading..."     +
+                      +
++++++++++++++++++++++++
- FooAPI.getAll()request resolves and triggers the- FooActionCreators.receiveAllSuccessaction
- FooStorereceive this action, updates its internal state, and emits change.
State 2:
- FooListcontroller-view component receive change event and update its state to get the list from the- FooStore
- this.state.fooList.sizeis no longer- == 0so the list can actually renders itself (note that we use- toJS()to explicitly get a raw javascript object since- Reactdoes not handle correctly mapping on not raw object yet).
- We're passing needed props to the FooListItemcomponent.
- By calling foo.getBar()we're telling to theFooStorethat we want theBarrecord back.
- getBar()method of- Foorecord retrieve the- Barrecord through the- BarStore
- BarStoredoes not have this- Barrecord in its- _barscache, so it triggers a request through- BarAPIto retrieve it.
- The same happens for all Foointhis.sate.fooListofFooListcontroller-view component
- The page now looks something like this:
++++++++++++++++++++++++
+                      +
+  Foo1 "name1"        +
+  Foo1 "baz1"         +
+  Foo1 bar:           +
+     "loading..."     +
+                      +
+  Foo2 "name2"        +
+  Foo2 "baz2"         +
+  Foo2 bar:           +
+     "loading..."     +
+                      +
+  Foo3 "name3"        +
+  Foo3 "baz3"         +
+  Foo3 bar:           +
+     "loading..."     +
+                      +
++++++++++++++++++++++++
-Now let's say the BarAPI.get(2) (requested by Foo2) resolves before BarAPI.get(1) (request by Foo1). Since it's asynchronous it's totally plausible.
- The BarAPI triggers the BAR_RECEIVED_SUCCESS' action via theBarActionCreators.
- TheBarStore` responds to this action by updating its internal store and emits change. That's the now the fun part...
State 3:
- The FooListcontroller-view component responds to theBarStorechange by updating its state.
- The rendermethod is called
- The foo.getBar()call now retrieve a realBarrecord fromBarStore. Since thisBarrecord has been effectively retrieved, theImmutablePureRenderMixinwill compare old props with current props and determine that theBarobjects has changed ! Bingo, we could re-render theFooListItemcomponent (a better approach here would be to create a separate FooListBarDetail component to let only this component to re-render, here we also re-rendering the Foo's details that have not changed but for the sake of simplicity let's just do that).
- The page now looks like this :
++++++++++++++++++++++++
+                      +
+  Foo1 "name1"        +
+  Foo1 "baz1"         +
+  Foo1 bar:           +
+     "loading..."     +
+                      +
+  Foo2 "name2"        +
+  Foo2 "baz2"         +
+  Foo2 bar:           +
+    "bar name"        +
+    "bar description" +
+                      +
+  Foo3 "name3"        +
+  Foo3 "baz3"         +
+  Foo3 bar:           +
+     "loading..."     +
+                      +
++++++++++++++++++++++++
If you want me to add more details from a non detailed part (such as action creators, constants, routing, etc., use of BarListDetail component with form, POST, etc.) just tell me in the comments :).