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!
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).
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 !
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With