In Flux architecture, how do you manage Store lifecycle?
Asked Answered
L

3

136

I'm reading about Flux but the example Todo app is too simplistic for me to understand some key points.

Imagine a single-page app like Facebook that has user profile pages. On each user profile page, we want to show some user info and their last posts, with infinite scroll. We can navigate from one user profile to another one.

In Flux architecture, how would this correspond to Stores and Dispatchers?

Would we use one PostStore per user, or would we have some kind of a global store? What about dispatchers, would we create a new Dispatcher for each “user page”, or would we use a singleton? Finally, what part of the architecture is responsible for managing the lifecycle of “page-specific” Stores in response to route change?

Moreover, a single pseudo-page may have several lists of data of the same type. For example, on a profile page, I want to show both Followers and Follows. How can a singleton UserStore work in this case? Would UserPageStore manage followedBy: UserStore and follows: UserStore?

Llanes answered 11/5, 2014 at 10:11 Comment(0)
C
124

In a Flux app there should only be one Dispatcher. All data flows through this central hub. Having a singleton Dispatcher allows it to manage all Stores. This becomes important when you need Store #1 update itself, and then have Store #2 update itself based on both the Action and on the state of Store #1. Flux assumes this situation is an eventuality in a large application. Ideally this situation would not need to happen, and developers should strive to avoid this complexity, if possible. But the singleton Dispatcher is ready to handle it when the time comes.

Stores are singletons as well. They should remain as independent and decoupled as possible -- a self-contained universe that one can query from a Controller-View. The only road into the Store is through the callback it registers with the Dispatcher. The only road out is through getter functions. Stores also publish an event when their state has changed, so Controller-Views can know when to query for the new state, using the getters.

In your example app, there would be a single PostStore. This same store could manage the posts on a "page" (pseudo-page) that is more like FB's Newsfeed, where posts appear from different users. Its logical domain is the list of posts, and it can handle any list of posts. When we move from pseudo-page to pseudo-page, we want to reinitialize the state of the store to reflect the new state. We might also want to cache the previous state in localStorage as an optimization for moving back and forth between pseudo-pages, but my inclination would be to set up a PageStore that waits for all other stores, manages the relationship with localStorage for all the stores on the pseudo-page, and then updates its own state. Note that this PageStore would store nothing about the posts -- that's the domain of the PostStore. It would simply know whether a particular pseudo-page has been cached or not, because pseudo-pages are its domain.

The PostStore would have an initialize() method. This method would always clear the old state, even if this is the first initialization, and then create the state based on the data it received through the Action, via the Dispatcher. Moving from one pseudo-page to another would probably involve a PAGE_UPDATE action, which would trigger the invocation of initialize(). There are details to work out around retrieving data from the local cache, retrieving data from the server, optimistic rendering and XHR error states, but this is the general idea.

If a particular pseudo-page does not need all the Stores in the application, I'm not entirely sure there is any reason to destroy the unused ones, other than memory constraints. But stores don't typically consume a great deal of memory. You just need to make sure to remove the event listeners in the Controller-Views you are destroying. This is done in React's componentWillUnmount() method.

Crocidolite answered 11/5, 2014 at 20:10 Comment(9)
There are certainly a few different approaches to what you want to do, and I think it depends on what you're trying to build. One approach would be a UserListStore, with all relevant the users in it. And each user would have a couple of boolean flags describing the relationship to the current user profile. Something like { follower: true, followed: false }, for example. The methods getFolloweds() and getFollowers() would retrieve the different sets of users you need for the UI.Crocidolite
Alternatively, you could have a FollowedUserListStore and a FollowerUserListStore that both inherit from an abstract UserListStore.Crocidolite
I have a small question - why not use pub sub to emit data from the stores directly rather than requiring the subscribers to retrieve the data?Stay
@Stay This would require the stores to keep track of what controller-views need what data. It's cleaner to have the stores publish the fact that they have changed in some way, and then let the interested controller-views retrieve which parts of the data they need.Crocidolite
What if I have a profile page where I show info about an user but also a list of his friends. Both user and friends would be the same type of that. Should they stay in the same store if so?Constructivism
What puzzles me in that "single Dispatcher" architecture is how to manage the massive amount of dispatcher events sitting on the same object. How do you ever avoid naming collision? This looks like exactly the same problem as with globals.Chrysanthemum
@DmitriZaitsev You can use a small module to handle event naming for you, or write your own JSON collection, ie: Events.App.AppLoaded = "EVENTS_APP_APPLOADED", etc...Introduce
This is ridiculous overkill to implement what is conceptually very simple functionality. What benefits of alt/flux offset this overkill?Memorable
The benefits are not seen until the app reaches a point where there is a lot of state being managed. I agree that for a simple widget, Flux is probably overkill. But for a large app with a great deal of client side state, Flux keeps the application very nicely organized. New team members pick it up very quickly and know where to make changes. New features are easy to develop and do not require extensive refactoring. If you have ever tried to create an ad on Facebook, then you know the kind of app I'm talking about. All of our ads interfaces are built with Flux.Crocidolite
L
79

(Note: I have used ES6 syntax using JSX Harmony option.)

As an exercise, I wrote a sample Flux app that allows to browse Github users and repos.
It is based on fisherwebdev's answer but also reflects an approach I use for normalizing API responses.

I made it to document a few approaches I have tried while learning Flux.
I tried to keep it close to real world (pagination, no fake localStorage APIs).

There are a few bits here I was especially interested in:

How I Classify Stores

I tried to avoid some of the duplication I've seen in other Flux example, specifically in Stores. I found it useful to logically divide Stores into three categories:

Content Stores hold all app entities. Everything that has an ID needs its own Content Store. Components that render individual items ask Content Stores for the fresh data.

Content Stores harvest their objects from all server actions. For example, UserStore looks into action.response.entities.users if it exists regardless of which action fired. There is no need for a switch. Normalizr makes it easy to flatten any API reponses to this format.

// Content Stores keep their data like this
{
  7: {
    id: 7,
    name: 'Dan'
  },
  ...
}

List Stores keep track of IDs of entities that appear in some global list (e.g. “feed”, “your notifications”). In this project, I don't have such Stores, but I thought I'd mention them anyway. They handle pagination.

They normally respond to just a few actions (e.g. REQUEST_FEED, REQUEST_FEED_SUCCESS, REQUEST_FEED_ERROR).

// Paginated Stores keep their data like this
[7, 10, 5, ...]

Indexed List Stores are like List Stores but they define one-to-many relationship. For example, “user's subscribers”, “repository's stargazers”, “user's repositories”. They also handle pagination.

They also normally respond to just a few actions (e.g. REQUEST_USER_REPOS, REQUEST_USER_REPOS_SUCCESS, REQUEST_USER_REPOS_ERROR).

In most social apps, you'll have lots of these and you want to be able to quickly create one more of them.

// Indexed Paginated Stores keep their data like this
{
  2: [7, 10, 5, ...],
  6: [7, 1, 2, ...],
  ...
}

Note: these are not actual classes or something; it's just how I like to think about Stores. I made a few helpers though.

StoreUtils

createStore

This method gives you the most basic Store:

createStore(spec) {
  var store = merge(EventEmitter.prototype, merge(spec, {
    emitChange() {
      this.emit(CHANGE_EVENT);
    },

    addChangeListener(callback) {
      this.on(CHANGE_EVENT, callback);
    },

    removeChangeListener(callback) {
      this.removeListener(CHANGE_EVENT, callback);
    }
  }));

  _.each(store, function (val, key) {
    if (_.isFunction(val)) {
      store[key] = store[key].bind(store);
    }
  });

  store.setMaxListeners(0);
  return store;
}

I use it to create all Stores.

isInBag, mergeIntoBag

Small helpers useful for Content Stores.

isInBag(bag, id, fields) {
  var item = bag[id];
  if (!bag[id]) {
    return false;
  }

  if (fields) {
    return fields.every(field => item.hasOwnProperty(field));
  } else {
    return true;
  }
},

mergeIntoBag(bag, entities, transform) {
  if (!transform) {
    transform = (x) => x;
  }

  for (var key in entities) {
    if (!entities.hasOwnProperty(key)) {
      continue;
    }

    if (!bag.hasOwnProperty(key)) {
      bag[key] = transform(entities[key]);
    } else if (!shallowEqual(bag[key], entities[key])) {
      bag[key] = transform(merge(bag[key], entities[key]));
    }
  }
}

PaginatedList

Stores pagination state and enforces certain assertions (can't fetch page while fetching, etc).

class PaginatedList {
  constructor(ids) {
    this._ids = ids || [];
    this._pageCount = 0;
    this._nextPageUrl = null;
    this._isExpectingPage = false;
  }

  getIds() {
    return this._ids;
  }

  getPageCount() {
    return this._pageCount;
  }

  isExpectingPage() {
    return this._isExpectingPage;
  }

  getNextPageUrl() {
    return this._nextPageUrl;
  }

  isLastPage() {
    return this.getNextPageUrl() === null && this.getPageCount() > 0;
  }

  prepend(id) {
    this._ids = _.union([id], this._ids);
  }

  remove(id) {
    this._ids = _.without(this._ids, id);
  }

  expectPage() {
    invariant(!this._isExpectingPage, 'Cannot call expectPage twice without prior cancelPage or receivePage call.');
    this._isExpectingPage = true;
  }

  cancelPage() {
    invariant(this._isExpectingPage, 'Cannot call cancelPage without prior expectPage call.');
    this._isExpectingPage = false;
  }

  receivePage(newIds, nextPageUrl) {
    invariant(this._isExpectingPage, 'Cannot call receivePage without prior expectPage call.');

    if (newIds.length) {
      this._ids = _.union(this._ids, newIds);
    }

    this._isExpectingPage = false;
    this._nextPageUrl = nextPageUrl || null;
    this._pageCount++;
  }
}

PaginatedStoreUtils

createListStore, createIndexedListStore, createListActionHandler

Makes creation of Indexed List Stores as simple as possible by providing boilerplate methods and action handling:

var PROXIED_PAGINATED_LIST_METHODS = [
  'getIds', 'getPageCount', 'getNextPageUrl',
  'isExpectingPage', 'isLastPage'
];

function createListStoreSpec({ getList, callListMethod }) {
  var spec = {
    getList: getList
  };

  PROXIED_PAGINATED_LIST_METHODS.forEach(method => {
    spec[method] = function (...args) {
      return callListMethod(method, args);
    };
  });

  return spec;
}

/**
 * Creates a simple paginated store that represents a global list (e.g. feed).
 */
function createListStore(spec) {
  var list = new PaginatedList();

  function getList() {
    return list;
  }

  function callListMethod(method, args) {
    return list[method].call(list, args);
  }

  return createStore(
    merge(spec, createListStoreSpec({
      getList: getList,
      callListMethod: callListMethod
    }))
  );
}

/**
 * Creates an indexed paginated store that represents a one-many relationship
 * (e.g. user's posts). Expects foreign key ID to be passed as first parameter
 * to store methods.
 */
function createIndexedListStore(spec) {
  var lists = {};

  function getList(id) {
    if (!lists[id]) {
      lists[id] = new PaginatedList();
    }

    return lists[id];
  }

  function callListMethod(method, args) {
    var id = args.shift();
    if (typeof id ===  'undefined') {
      throw new Error('Indexed pagination store methods expect ID as first parameter.');
    }

    var list = getList(id);
    return list[method].call(list, args);
  }

  return createStore(
    merge(spec, createListStoreSpec({
      getList: getList,
      callListMethod: callListMethod
    }))
  );
}

/**
 * Creates a handler that responds to list store pagination actions.
 */
function createListActionHandler(actions) {
  var {
    request: requestAction,
    error: errorAction,
    success: successAction,
    preload: preloadAction
  } = actions;

  invariant(requestAction, 'Pass a valid request action.');
  invariant(errorAction, 'Pass a valid error action.');
  invariant(successAction, 'Pass a valid success action.');

  return function (action, list, emitChange) {
    switch (action.type) {
    case requestAction:
      list.expectPage();
      emitChange();
      break;

    case errorAction:
      list.cancelPage();
      emitChange();
      break;

    case successAction:
      list.receivePage(
        action.response.result,
        action.response.nextPageUrl
      );
      emitChange();
      break;
    }
  };
}

var PaginatedStoreUtils = {
  createListStore: createListStore,
  createIndexedListStore: createIndexedListStore,
  createListActionHandler: createListActionHandler
};

createStoreMixin

A mixin that allows components to tune in to Stores they're interested in, e.g. mixins: [createStoreMixin(UserStore)].

function createStoreMixin(...stores) {
  var StoreMixin = {
    getInitialState() {
      return this.getStateFromStores(this.props);
    },

    componentDidMount() {
      stores.forEach(store =>
        store.addChangeListener(this.handleStoresChanged)
      );

      this.setState(this.getStateFromStores(this.props));
    },

    componentWillUnmount() {
      stores.forEach(store =>
        store.removeChangeListener(this.handleStoresChanged)
      );
    },

    handleStoresChanged() {
      if (this.isMounted()) {
        this.setState(this.getStateFromStores(this.props));
      }
    }
  };

  return StoreMixin;
}
Llanes answered 25/8, 2014 at 12:52 Comment(10)
Given the fact that you've written Stampsy, if you would rewrite the whole client side application, would you use FLUX and the same approapch you used for building this example app?Tims
eAbi: This is the approach we're currently using as we are rewriting Stampsy in Flux (hoping to release it next month). It's not ideal but it works well for us. When/if we figure out better ways to do that stuff, we will share them.Llanes
eAbi: However we don't use normalizr anymore because a guy one our team rewrote all our APIs to return normalized responses. It was useful before that was done though.Llanes
Thanks for your information. I've checked your github repo and I'm trying to begin a project (built in YUI3) with your approach, but I'm having some troubles compiling the code (if you can say so). I'm not running the server under node so I wanted to copy the source to my static directory but I still have to do some work... It's a bit cumbersome, and also, I found some files having different JS syntax. Especially in jsx files.Tims
By normalizing the server response you'll increase the number of requests though. The first API version on our server was normalized but we changed to a nested model afterwards. It really depends on your business logic and how many dependent resources do you need to query for each parent resource (in case of one-to-many relationships).Tims
eABI: I'm not familiar with YUI but Flux is architecture for React apps (and those files with different syntax are React JSX). As for nesting, I'm not sure why you say normalization increases request count. In my understanding any nested response can equally well be represented as normalized, the only difference is normalized being more easily consumable by stores. Finally, you can always implement request batching (both on client and server), it fits well with Flux because components don't care how data is fetched and batching can be abstracted away in API layer.Llanes
Let us continue this discussion in chat.Tims
I noticed that in your example app, your action creators reference your stores (e.g. if (UserStore.contains...). While you're just reading the store and not updating state, this still seems a bit backwards to me since the flow is Actions -> Stores -> Components. I was trying to avoid something like this, trying to keep actions store-agnostic, but I'm curious if this has been a problem for you or not.Zaccaria
@Sean: I don't see it as a problem at all. The data flow is about writing data, not reading it. Sure it's best if actions are agnostic of stores, but for optimizing requests I think it's perfectly fine to read from stores. After all, components read from stores and fire those actions. You could repeat this logic in every component, but that's what action creator is for..Llanes
@DanAbramov I'm using Normalizr on the server-side. I get nested responses from Thinky orm and it splits them apart for me. Nice for quick and dirty apis.Mojgan
T
27

So in Reflux the concept of the Dispatcher is removed and you only need to think in terms of data flow through actions and stores. I.e.

Actions <-- Store { <-- Another Store } <-- Components

Each arrow here models how the data flow is listened to, which in turn means that the data flows in the opposite direction. The actual figure for data flow is this:

Actions --> Stores --> Components
   ^          |            |
   +----------+------------+

In your use case, if I understood correctly, we need a openUserProfile action that initiates the user profile loading and switching the page and also some posts loading actions that will load posts when the user profile page is opened and during the infinite scroll event. So I'd imagine we have the following data stores in the application:

  • A page data store that handles switching pages
  • A user profile data store that loads the user profile when the page is opened
  • A posts list data store that loads and handles the visible posts

In Reflux you'd set it up like this:

The actions

// Set up the two actions we need for this use case.
var Actions = Reflux.createActions(['openUserProfile', 'loadUserProfile', 'loadInitialPosts', 'loadMorePosts']);

The page store

var currentPageStore = Reflux.createStore({
    init: function() {
        this.listenTo(openUserProfile, this.openUserProfileCallback);
    },
    // We are assuming that the action is invoked with a profileid
    openUserProfileCallback: function(userProfileId) {
        // Trigger to the page handling component to open the user profile
        this.trigger('user profile');

        // Invoke the following action with the loaded the user profile
        Actions.loadUserProfile(userProfileId);
    }
});

The user profile store

var currentUserProfileStore = Reflux.createStore({
    init: function() {
        this.listenTo(Actions.loadUserProfile, this.switchToUser);
    },
    switchToUser: function(userProfileId) {
        // Do some ajaxy stuff then with the loaded user profile
        // trigger the stores internal change event with it
        this.trigger(userProfile);
    }
});

The posts store

var currentPostsStore = Reflux.createStore({
    init: function() {
        // for initial posts loading by listening to when the 
        // user profile store changes
        this.listenTo(currentUserProfileStore, this.loadInitialPostsFor);
        // for infinite posts loading
        this.listenTo(Actions.loadMorePosts, this.loadMorePosts);
    },
    loadInitialPostsFor: function(userProfile) {
        this.currentUserProfile = userProfile;

        // Do some ajax stuff here to fetch the initial posts then send
        // them through the change event
        this.trigger(postData, 'initial');
    },
    loadMorePosts: function() {
        // Do some ajaxy stuff to fetch more posts then send them through
        // the change event
        this.trigger(postData, 'more');
    }
});

The components

I'm assuming you have a component for the whole page view, the user profile page and the posts list. The following needs to be wired up:

  • The buttons that opens up the user profile need to invoke the Action.openUserProfile with the correct id during it's click event.
  • The page component should be listening to the currentPageStore so it knows which page to switch to.
  • The user profile page component needs to listen to the currentUserProfileStore so it knows what user profile data to show
  • The posts list needs to listen to the currentPostsStore to receive the loaded posts
  • The infinite scroll event needs to call the Action.loadMorePosts.

And that should be pretty much it.

Terwilliger answered 31/7, 2014 at 12:31 Comment(1)
A bit late to the party maybe, but here is a nice article explaining why to avoid calling you API directly from stores. I'm still figuring out what the best practices are, but I thought it might help other stumbling on this. There's a lot of different approaches floating around with regard to stores.Polled

© 2022 - 2024 — McMap. All rights reserved.