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
CurrentItemlistens toItems, your data will still be in sync, but it will make everything else much easier. Then, you just connectCurrentItemtocomponent.state.currentItemand isomorphic rendering is easy.