Skip to content

Instantly share code, notes, and snippets.

@gpbl
Last active November 3, 2015 13:19
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gpbl/ec05807f84b2fc8a970d to your computer and use it in GitHub Desktop.
Save gpbl/ec05807f84b2fc8a970d to your computer and use it in GitHub Desktop.
Using Reflux stores and actions for a Master/Detail app with React

Reflux stores and actions in a Master/Detail app

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.

Requirements

Must work with a router

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).

Use a single store for both master and details

Both these views must use a single itemStore with the relative itemActions.

Data is cached in the store and components can reuse cached data

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.

Listening multiple stores

This achitecture should make clear what happens when a component listens to multiple stores.

itemActions.js

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;

itemStore.js

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;
  }

});

MasterView.jsx

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;

DetailView.jsx

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;
@appsforartists
Copy link

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:

Use a single store for both master and details

Why is that a requirement? You can have one store represent all the items, and another store represent the current item. If CurrentItem listens to Items, your data will still be in sync, but it will make everything else much easier. Then, you just connect CurrentItem to component.state.currentItem and isomorphic rendering is easy.

@appsforartists
Copy link

@gpbl, look at the reflux tree in the Ambidex example, specifically Bikes and CurrentBike.

They're architected a bit different than yours, but I think you'll find them helpful (esp. the separation between all of something and the current one). There's not yet a concept of Loading, but that can be layered on.

@gpbl
Copy link
Author

gpbl commented Jan 6, 2015

thanks @appsforartists for the tip, I thought about it and looked into your code. In my architecture it won't work as I hoped.

I'd like to use the store's getInitialState() in pair with the component's getInitialState() when hydrating the stores. When the user load first the Item View, its currentItemStore would have the initial state set to the first hydrated item. Subsequent requests would still think the initial state is that first item, while it's not.
Well this is part of another story, let see if i come out of it...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment