Example and reasonings when using Reflux stores and actions for a Master/Detail app with React – with an eye to server-side rendering. Some concepts apply to reflux#166 and reflux#180.
This hypotetical app would use a router to display a list of items (e.g. at the url example.org/items
, the master view) and a single item (e.g. example.org/items/:id
, the detail view).
Both these views must use a single itemStore
with the relative itemActions
.
When switching from the master- to the item-view, the user should be able to reuse the item already saved in the store, without requesting the server again.
This achitecture should make clear what happens when a component listens to multiple stores.
Implements a load
, load.complete
, load.failed
set of actions to work with a simple RESTful API request.
The load()
action may receive an id
as argument, to request a single item from a /api/items/:id
endpoint. Otherwise it will request /api/items
to get all the items.
import { createActions } from 'reflux';
import { request } from '../api'; // simple api
var actions = createActions({
'load': {children: ['completed','failed']}
});
actions.load.listen((id) => {
request(id).end((err, res) => {
if (err) {
actions.load.failed(err);
return;
}
// request api/item/:id to return a single item:
// { id: 1, name: "Item 1" }
// otherwise api/items to return an array of items:
// { items: [ {id: 1, name: "Item 1"}, {id: 2, name: "Item2 "}, ... ] }
var payload = {};
if (id) payload[id] = res.body;
else payload = res.body.items;
actions.load.completed(payload)
});
});
export default actions;
This store caches the requested item in the items
property. When all the items are loaded, it will set the loaded
property to true
so that the store consumers (e.g. a jsx component) will know if a API request is needed.
import { indexBy, assign } from 'lodash';
import { createStore } from 'reflux';
import itemActions from '../actions/itemsActions';
export default createStore({
listenables: itemActions,
items: {},
loaded: false,
get(id) {
return this.items[id];
},
onLoadCompleted(items) {
if (items instanceof Array) {
// Loaded all items (e.g. master view)
items = indexBy(items, 'id');
this.loaded = true;
}
assign(this.items, items);
this.trigger(this.items);
},
onLoadFailed(err) {
// boom
},
getInitialState() {
return this.items;
}
});
The master view component will get the initial state.items
from the store's getInitialState()
method. The component will have the loading
state true
if the store is not yet loaded.
When rehydrating data from a server-side rendering, store.items
should be already populated with store.loaded
set to true
.
The componentDidMount()
method will ask the actions to load the items if store.loaded
is false.
Note that I can't use the connect(store, 'items')
reflux mixin, since I need to set the loading
state in the store handler.
import React from 'react';
import { ListenerMixin } from 'reflux';
import { map } from 'lodash';
import itemsStore from './stores/itemsStore';
import itemsActions from './actions/itemsActions';
const MasterView = React.createClass({
mixins: [ListenerMixin],
getInitialState() {
return {
loading: !itemsStore.loaded,
items: itemsStore.getInitialState()
}
},
componentDidMount() {
this.listenTo(itemsStore, this.handleLoadItemsComplete);
if (!itemsStore.loaded) itemActions.load();
},
handleLoadItemsComplete(items) {
this.setState({
loading: false,
items: items
});
},
render() {
if (this.state.loading)
return <p>Loading items...</p>
return (
<div>
{
map(this.state.items, (item) => {
<a href={ "/items/" + item.id }>Item { item.id }</a>
})
}
</div>
);
}
});
export default MasterView;
The detail view shows the Item relative to the id
prop. Since this view is initialized by the router (e.g. a /item/:id
path), we can't have the whole item
object as prop here, and we need to connect the component to the store.
The getInitialState()
method will set the loading
state to true
if the store does not contain the item (i.e. store.get(id)
return undefined
).
Note that this component does not have a item
state, rather it will accept all the items
coming from the store. The render()
method will pick the item for the desired id
by using the store.get()
method.
import React from 'react';
import { ListenerMixin } from 'reflux';
import itemsStore from './stores/itemsStore';
import itemsActions from './actions/itemsActions';
const DetailView = React.createClass({
propTypes: {
id: React.PropTypes.number // item id
},
mixins: [ListenerMixin],
getInitialState() {
const items = itemsStore.getInitialState().items;
return {
loading: !itemsStore.get(this.props.id),
items: items
}
},
componentDidMount() {
this.listenTo(itemsStore, this.handleLoadItemComplete);
if (!itemsStore.get(this.props.id))
this.fetchData();
},
componentWillReceiveProps(nextProps) {
if (!itemsStore.get(nextProps.id))
this.fetchData();
},
fetchData() {
this.setState({ loading: true }, () => {
itemsActions.load(this.props.id);
});
},
handleLoadItemComplete(items) {
this.setState({
loading: false,
items: items
});
},
render() {
if (this.state.loading)
return <p>Loading item {this.props.id}...</p>;
// Get the item we want to display
const item = itemsStore.get(this.props.id);
if (!item)
return <p>Item {this.props.id} not found!</p>;
return (
<div>
Item with id={this.props.id} loaded successfully!
</div>
);
}
});
export default DetailView;
I sadly don't have time to read and understand all of this right now, but a quick skim of your requirements brought this to my attention:
Why is that a requirement? You can have one store represent all the items, and another store represent the current item. If
CurrentItem
listens toItems
, your data will still be in sync, but it will make everything else much easier. Then, you just connectCurrentItem
tocomponent.state.currentItem
and isomorphic rendering is easy.