How to handle nested api calls in flux
Asked Answered
F

3

12

I'm creating a simple CRUD app using Facebook's Flux Dispatcher to handle the creation and editing of posts for an English learning site. I currently am dealing with an api that looks like this:

/posts/:post_id
/posts/:post_id/sentences
/sentences/:sentence_id/words
/sentences/:sentence_id/grammars

On the show and edit pages for the app, I'd like to be able to show all the information for a given post as well as all of it's sentences and the sentences' words and grammar details all on a single page.

The issue I'm hitting is figuring out how to initiate all the async calls required to gather all this data, and then composing the data I need from all the stores into a single object that I can set as the state in my top level component. A current (terrible) example of what I've been trying to do is this:

The top level PostsShowView:

class PostsShow extends React.Component {
  componentWillMount() {
    // this id is populated by react-router when the app hits the /posts/:id route
    PostsActions.get({id: this.props.params.id});

    PostsStore.addChangeListener(this._handlePostsStoreChange);
    SentencesStore.addChangeListener(this._handleSentencesStoreChange);
    GrammarsStore.addChangeListener(this._handleGrammarsStoreChange);
    WordsStore.addChangeListener(this._handleWordsStoreChange);
  }

  componentWillUnmount() {
    PostsStore.removeChangeListener(this._handlePostsStoreChange);
    SentencesStore.removeChangeListener(this._handleSentencesStoreChange);
    GrammarsStore.removeChangeListener(this._handleGrammarsStoreChange);
    WordsStore.removeChangeListener(this._handleWordsStoreChange);
  }

  _handlePostsStoreChange() {
    let posts = PostsStore.getState().posts;
    let post = posts[this.props.params.id];

    this.setState({post: post});

    SentencesActions.fetch({postId: post.id});
  }

  _handleSentencesStoreChange() {
    let sentences = SentencesStore.getState().sentences;

    this.setState(function(state, sentences) {
      state.post.sentences = sentences;
    });

    sentences.forEach((sentence) => {
      GrammarsActions.fetch({sentenceId: sentence.id})
      WordsActions.fetch({sentenceId: sentence.id})
    })
  }

  _handleGrammarsStoreChange() {
    let grammars = GrammarsStore.getState().grammars;

    this.setState(function(state, grammars) {
      state.post.grammars = grammars;
    });
  }

  _handleWordsStoreChange() {
    let words = WordsStore.getState().words;

    this.setState(function(state, words) {
      state.post.words = words;
    });
  }
}

And here is my PostsActions.js - the other entities (sentences, grammars, words) also have similar ActionCreators that work in a similar way:

let api = require('api');

class PostsActions {
  get(params = {}) {
    this._dispatcher.dispatch({
      actionType: AdminAppConstants.FETCHING_POST
    });

    api.posts.fetch(params, (err, res) => {
      let payload, post;

      if (err) {
        payload = {
          actionType: AdminAppConstants.FETCH_POST_FAILURE
        }
      }
      else {
        post = res.body;

        payload = {
          actionType: AdminAppConstants.FETCH_POST_SUCCESS,
          post: post
        }
      }

      this._dispatcher.dispatch(payload)
    });
  }
}

The main issue is that the Flux dispatcher throws a "Cannot dispatch in the middle of a dispatch" invariant error when SentencesActions.fetch is called in the _handlePostsStoreChange callback because that SentencesActions method triggers a dispatch before the dispatch callback for the previous action is finished.

I'm aware that I can fix this by using something like _.defer or setTimeout - however that really feels like I'm just patching the issue here. Also, I considered doing all this fetching logic in the actions itself, but that seemed not correct either, and would make error handling more difficult. I have each of my entities separated out into their own stores and actions - shouldn't there be some way in the component level to compose what I need from each entity's respective stores?

Open to any advice from anyone who has accomplished something similar!

Friulian answered 27/8, 2015 at 3:40 Comment(4)
have you tried using waitFor? facebook.github.io/flux/docs/dispatcher.htmlAlexandria
@Alexandria Yes, I've attempted to use waitFor, but it didn't really seem to address the issue, since the issue is that a second action gets dispatched before the first one can finish. However, maybe my understanding of waitFor is wrong and I'm just not using it correctly?Friulian
@joeellis: is it possible for you to put together a jsFiddle demo please demonstrating your problem situation?Omnivorous
It is difficult to say without seeing all of the code, but that first call to PostActions.get() is triggering a global change, which is triggering _handlePostsStoreChange, which then invokes SentencesActions.fetch() before the initial dispatch is finished. I would recommend more granular events, i.e. register a "ON_POST_FETCH" event that triggers your loading gif on/off when you emitFetchChange(), and register a specific "POST_DATA_CHANGED" event to respond to emitPostDataChange() and call your SentencesActions.fetch(). Not sure if this will help, but I've solved similar issues this way.Togetherness
G
5

But no, there is no hack to create an action in the middle of a dispatch, and this is by design. Actions are not supposed to be things that cause a change. They are supposed to be like a newspaper that informs the application of a change in the outside world, and then the application responds to that news. The stores cause changes in themselves. Actions just inform them.

Also

Components should not be deciding when to fetch data. This is application logic in the view layer.

Bill Fisher, creator of Flux https://mcmap.net/q/129831/-flux-dispatch-dispatch-cannot-dispatch-in-the-middle-of-a-dispatch

Your component is deciding when to fetch data. That is bad practice. What you basically should be doing is having your component stating via actions what data it does need.

The store should be responsible for accumulating/fetching all the needed data. It is important to note though, that after the store requested the data via an API call, the response should trigger an action, opposed to the store handling/saving the response directly.

Your stores could look like something like this:

class Posts {
  constructor() {
    this.posts = [];

    this.bindListeners({
      handlePostNeeded: PostsAction.POST_NEEDED,
      handleNewPost: PostsAction.NEW_POST
    });
  }

  handlePostNeeded(id) {
    if(postNotThereYet){
      api.posts.fetch(id, (err, res) => {
        //Code
        if(success){
          PostsAction.newPost(payLoad);
        }
      }
    }
  }

  handleNewPost(post) {
    //code that saves post
    SentencesActions.needSentencesFor(post.id);
  }
}

All you need to do then is listening to the stores. Also depending if you use a framework and which one you need to emit the change event (manually).

Graf answered 1/9, 2015 at 17:30 Comment(1)
Thanks for this (and to all the other answers). It sounds like where I was going wrong was I was assuming the top level smart component should be acting as a view-controller type, i.e. it should be able to act against all the actions and stores it needs to compose / assemble all the state it's children components need to use. It seems that idea is faulty, and the store should really be handling these async requests. It's a shame though, as I would have much rather had the actions be responsible for them, but alas, I think I've proven that is not possible with the current flux paradigm.Friulian
C
2

I think you should have different Store reflecting your data models and some POJO's objects reflecting instances of your object. Thus, your Post object will have a getSentence() methods which in turns will call the SentenceStore.get(id) etc. You just need to add a method such as isReady() to your Post object returning true or `false wether all the datas has been fetched or not.

Here is a basic implementation using ImmutableJS:

PostSore.js

var _posts = Immutable.OrderedMap(); //key = post ID, value = Post

class Post extends Immutable.Record({
    'id': undefined,
    'sentences': Immutable.List(),
}) {

    getSentences() {
        return SentenceStore.getByPost(this.id)
    }

    isReady() {
        return this.getSentences().size > 0;
    }
}

var PostStore = assign({}, EventEmitter.prototype, {

    get: function(id) {
        if (!_posts.has(id)) { //we de not have the post in cache
            PostAPI.get(id); //fetch asynchronously the post
            return new Post() //return an empty Post for now
        }
        return _post.get(id);
    }
})

SentenceStore.js

var _sentences = Immutable.OrderedMap(); //key = postID, value = sentence list

class Sentence extends Immutable.Record({
    'id': undefined,
    'post_id': undefined,
    'words': Immutable.List(),
}) {

    getWords() {
        return WordsStore.getBySentence(this.id)
    }

    isReady() {
        return this.getWords().size > 0;
    }
}

var SentenceStore = assign({}, EventEmitter.prototype, {

    getByPost: function(postId) {
        if (!_sentences.has(postId)) { //we de not have the sentences for this post yet
            SentenceAPI.getByPost(postId); //fetch asynchronously the sentences for this post
            return Immutable.List() //return an empty list for now
        }
        return _sentences.get(postId);
    }
})

var _setSentence = function(sentenceData) {
    _sentences = _sentences.set(sentenceData.post_id, new Bar(sentenceData));
};

var _setSentences = function(sentenceList) {
    sentenceList.forEach(function (sentenceData) {
        _setSentence(sentenceData);
    });
};

SentenceStore.dispatchToken = AppDispatcher.register(function(action) {
    switch (action.type)
    {   
        case ActionTypes.SENTENCES_LIST_RECEIVED:
            _setSentences(action.sentences);
            SentenceStore.emitChange();
            break;
    }
});

WordStore.js

var _words = Immutable.OrderedMap(); //key = sentence id, value = list of words

class Word extends Immutable.Record({
    'id': undefined,
    'sentence_id': undefined,
    'text': undefined,
}) {

    isReady() {
        return this.id != undefined
    }
}

var WordStore = assign({}, EventEmitter.prototype, {

    getBySentence: function(sentenceId) {
        if (!_words.has(sentenceId)) { //we de not have the words for this sentence yet
            WordAPI.getBySentence(sentenceId); //fetch asynchronously the words for this sentence
            return Immutable.List() //return an empty list for now
        }
        return _words.get(sentenceId);
    }

});

var _setWord = function(wordData) {
    _words = _words.set(wordData.sentence_id, new Word(wordData));
};

var _setWords = function(wordList) {
    wordList.forEach(function (wordData) {
        _setWord(wordData);
    });
};

WordStore.dispatchToken = AppDispatcher.register(function(action) {
    switch (action.type)
    {   
        case ActionTypes.WORDS_LIST_RECEIVED:
            _setWords(action.words);
            WordStore.emitChange();
            break;
    }

});

By doing this, you only need to listen to above stores change in your component and write something like this (pseudo code)

YourComponents.jsx

getInitialState:
    return {post: PostStore.get(your_post_id)}

componentDidMount:
    add listener to PostStore, SentenceStore and WordStore via this._onChange

componentWillUnmount:
    remove listener to PostStore, SentenceStore and WordStore

render:
    if this.state.post.isReady() //all data has been fetched

    else
        display a spinner        

_onChange:
    this.setState({post. PostStore.get(your_post_id)})

When the user hits the page, PostStore will first retrieve the Post object via Ajax and the needed data will be loaded by SentenceStore and WordStore. Since we are listening to them and the isReady() method of Post only returns true when post's sentences are ready, and isReady() method of Sentence only returns true when all its words have been loaded, you have nothing to do :) Just wait for the spinner to be replaced by your post when your data is ready !

Competent answered 2/9, 2015 at 16:19 Comment(0)
D
0

I don't know how your application state is handled, but for me the system that always works best when I encounter issues with Flux is to move more state and more logic to the store. I have tried to get around this a number of times, and it always ends up biting me. So in the most simple example, I would dispatch one action that handles the entire request, as well as any state that goes along with it. Here is a very simple example, that should be relatively Flux framework-agnostic:

var store = {
  loading_state: 'idle',
  thing_you_want_to_fetch_1: {},
  thing_you_want_to_fetch_2: {}
}

handleGetSomethingAsync(options) {
  // do something with options
  store.loading_state = 'loading'
  request.get('/some/url', function(err, res) {
    if (err) {
      store.loading_state = 'error';
    } else {
      store.thing_you_want_to_fetch_1 = res.body;
      request.get('/some/other/url', function(error, response) {
        if (error) {
          store.loading_state = 'error';
        } else {
          store.thing_you_want_to_fetch_2 = response.body;
          store.loading_state = 'idle';
        }
      }
    }
  }
}

Then in your React components you use the store.loading_state to determine whether to render some kind of loading spinner, an error, or the data as normal.

Notice that in this case the action does nothing more than pass an options object down to a store method which then handles all of the logic and state associated with the multiple requests in one place.

Let me know if I can explain any of this better.

Dubbing answered 2/9, 2015 at 18:24 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.