Flux was presented in May 2014 and quickly become a new movement in web development. Today Flux isn't that widely used. The driver seat was taken by its offspring Redux. Anyway, it's still interesting to discuss some of the issues with Flux's architecture about which you don't even think in Redux.
This one was famous:
"Cannot dispatch in the middle of a dispatch"
This error had to mean that you did dispatch()
in a wrong time and need to move it somewhere else. The most brave people just ignored it by wrapping the dispatch into setTimeout()
. But there were many other hacks to avoid it.
Flux's official website and issue tracker have no good explanation of how to deal with this problem only recommending not to dispatch. Unfortunately, there're too many scenarios when it's unavoidable. As you'll see later this error is only a symptom of a much larger issue.
Flux describes a store as a state manager of a domain. That means you will have more stores than 1. Same time, some stores may depend on another, what is described by calling waitFor()
method.
Imagine a basic app of two components:
<App>
<Posts />
</App>
The App is the root and shows a login screen instead of its children while user isn't authenticated. The Posts component starts loading its data in componentDidMount()
hook what is the recommended practice. Both these components depend on different stores: AppStore
and PostsStore
. The PostsStore
may also depend on the AppStore
too, but it isn't important.
Let's look at the time when user just authenticated and the app got a positive answer from the server with user's session:
Actions are represented as arrow-like blocks. Let's follow the diagram:
-
AUTH_SUCCESS
is dispatched. Flux Dispatcher starts calling stores' callbacks and does this in order. -
AppStore
's callback is called first, the store recalculates its state. - All
AppStore
subscribers start to update. We have only one subscriber in our case -- theApp
component. - The state was updated, and the
App
starts to re-render. - This time
isAuth
istrue
and we start renderingPosts
(this happens synchronously). -
componentDidMount()
also happens synchronously. So, just right after the initialPosts
render we start loading actual posts (Posts
shows aLoader
). - Loading posts means dispatching
LOAD_POSTS_STARTED
first. - What means we're back in the Flux Dispatcher, which will throw the nasty error.
Now look at the #5
. When render happens we're still in the middle of dispatch. That means that only a part of stores were updated and we're looking at inconsistent state. Not only we're getting errors in totally normal scenarios, but even without errors the situation is hardly better.
The most popular solution to this entire scope of issues is to fire change event in setTimeout()
. But this removes synchronicity of React rendering. Theoretically, event subscribers may be called in different order, because order of execution of setTimeout
callbacks is unspecified (even if we know that browsers just add them to a queue).
I like another solution which isn't that well known, but lies on surface. Redux works this way and is consistent, error-less, and synchronous. The whole dispatch process inside Redux may be written as such:
dispatch(action) {
this.$state = this.$reducer(this.$state, action);
this.$emit();
}
It calculates the new state and only then calls subscribers. The state is always consistent, and the entire process is like an atomic DB transaction.
In Flux this approach would be more verbose, but still doable. Stores manage their subscribers individually, but they could return a function to dispatcher. This function will call store's emit()
. Most of the time stores don't pass event arguments, so they would just return the emit
itself. In case if you want to optimize some things and filter events based on args, a store may return a custom callback.
Taking Flux Dispatcher as the base only a few places require tweaks:
dispatch(payload){
// No more "Cannot dispatch..."
this._startDispatching(payload);
// Same try/finally as before.
// After state calculation notify all subscribers.
this._notifyAll();
}
_notifyAll() {
// In case of a nested dispatch just ignore.
// The topmost call will handle all notifications.
if (!this._isNotifying) {
this._isNotifying = true;
while (this._notifyQueue.length > 0) {
const notify = this._notifyQueue.shift();
notify();
}
this._isNotifying = false;
}
}
_invokeCallback(id) {
this._isPending[id] = true;
// Save callback from the store to the queue.
const notify = this._callbacks[id](this._pendingPayload);
if (notify) {
this._notifyQueue.push(notify);
}
this._isHandled[id] = true;
}
It requires some error handling code, but the idea should be clear. This is how a store may look like:
class PostsStore extends EventEmitter {
constructor(dispatcher) {
this.$emit = this.$emit.bind(this);
this.$posts = {};
this.dispatchToken = dispatcher.register(payload => {
switch (payload.actionType) {
case "LOAD_POSTS_SUCCESS":
// Don't forget to "return" here
return this.$loadPosts(payload);
}
};
}
$loadPosts(payload) {
this.$posts[payload.userId] = payload.posts;
return this.$emit; // A generic case with no args;
}
$clearPosts(userId) {
delete this.$posts[userId];
// When only a part of subscribers should update.
return () => this.$emit(userId);
}
}
The rest of the app's code stays the same.
This solution has not that big refactoring penalty, but gives you state consistency, removes unnecessary errors, and keeps update-render process synchronous and simple to follow.
Atomicity is a nice property which we hadn't in Flux and not always notice in Redux. Redux is also more simple, maybe that's why we (the community) haven't seen implementations like Atomic Flux Dispatcher and moved forward straight to Redux.
Originally posted on Medium in 2019.
Top comments (0)