Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to handle nested api calls in flux

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!

like image 399
joeellis Avatar asked Aug 27 '15 03:08

joeellis


2 Answers

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://stackoverflow.com/a/26581808/4258088

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

like image 160
MoeSattler Avatar answered Nov 11 '22 10:11

MoeSattler


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 !

like image 2
Pierre Criulanscy Avatar answered Nov 11 '22 11:11

Pierre Criulanscy