The author of the very popular JavaScript framework Redux - Dan Abramov admitted that he drew some amount of inspiration from The Elm Architecture when he started thinking about Redux
. So I was wondering, what were the differences, and most importantly, why did he make the framework different? I can imagine that the popularity of Elm is vastly penalized by the language itself. Everybody knows JavaScript (at least some small portion), but only a few are willing to learn a brand new language - albeit one that is not that different from JavaScript. Also, keep in mind that Elm is a strictly typed language, which may or may not be an advantage.
I'm not planning to ignite a flamewar about whether Elm or JavaScript is the right choice. What matters here is The Elm Architecture. There is one fundamental and obvious distinction between Redux and The Elm Architecture. In the Elm updater function, along with application state, the programmer returns “side-effect intention”, which is an object describing which function to call and its parameters. In Redux, this means that instead of returning an object containing app state from reducers, you need to return a tuple containing application state and a list of declarative effects.
const reduction = {
state: {
counter: 42
},
effects: [{
type: 'CALL_API',
payload: { url: '/analytics/track-increment' }
}]
}
Everything is still technically pure because side effects are not executed in the reducer, but the framework (middleware in Redux, language runtime in Elm) is responsible for executing them out of order. The premise of Redux is that you should not mix state transitions with anything else except state mutations. If you need side effect, do it before you dispatch the action.
I loved Elm, until I didn't
The Elm Architecture always seemed a natural choice to me. I wanted to stick with JavaScript on a daily basis, so anytime I experimented with Elm the scope was fairly limited. I have always adored Elm as a language, but still couldn't afford to make a full transition from JavaScript to Elm. So, I came up with an idea. What if we replicated Elm's model of dealing with side effects in the Redux ecosystem? There are countless projects on github which are trying to achieve the same goal in what I think is a more or less awkward way. So let's take a closer look at one of the most well known projects: redux-loop
.
import { loop, Effects } from 'redux-loop';
const reducer = (state, action) {
switch (action.type) {
case 'BUTTON_CLICKED':
return loop(
{ ...state, showLoadingSpinner: true },
Effects.promise(fetchData, state.loggedUserId)
);
default:
return state;
}
};
The loop
function takes two arguments; mutated state and either effect or list of effects. This is impractical with nested reducers because as a developer, you must always keep in mind that the returned reduction might potentially be a tuple of State and Effects instead of just State. But, let's put this issue aside so that we can focus on why the idea seemed great at first glance.
Imagine this e-commerce use-case: When a customer clicks the buy button, the application should send the item on the server and add it to the shopping basket.
There are two business-related UI consenquences:
- intention to store the item on the server
- add the item to basket
Pay extra attention to word intention. From a business perspective, the intention to store data is important, not the actual execution. It's not a business problem if we store the data via XHR or WS, it's just a detail of the service layer and not the domain layer. So we can easily describe that intention as an object {type: 'StoreItem', payload: itemId}
which declaratively states what should happen (type
) and how it should be parameterized (payload
).
But the important part is the conjunction "and" between those two consequences. The use-case explicitly specifies that those two consequences are transactional. People (I was one of them) usually tend to advocate for the idea of declarative effects in reducers by claiming that side-effect intentions are inevitably part of your domain logic and therefore should live in reducers because that's where the meat is.
This is definitely a valid point, but only under one important condition; the source of truth is application state. This is contrary to the original idea of Event Sourcing, where the source of truth is not state, but event log. Most people have already accepted the fact that the principal idea of Redux is Event Sourcing. Greg Young once claimed that "Current State is a Left Fold of previous behaviours.". Still not convinced? What about this:
const transactions = [100, -100, 200, 300];
// event is either positive number (received money)
// or negative number (spent money)
const sum = (state, event) => state + event;
const currentBalance = transactions.reduce(sum, 0);
Need something more Redux-friendly?
const currentState = actions.reduce(reducer, initialState);
I always thought that we should treat Actions as Events in Event Sourcing context, but when a user clicks the button, it doesn't necessarily need to correlate with a single action! Because in the real world, user interaction is considered Command and definitely not Event! I even once claimed the exact opposite in order to advocate the importance of Elmish side-effect model. Apparently, I was wrong.
Event Log is source of truth, State is just a projection
Imagine a simple TodoMVC application with an additional business requirement which may eventually be an easter egg. When a user creates three random Todos and the fourth Todo will be with the text "foobar", it should display a hidden message somewhere in the UI.
const businessPredicate = (appState) => {
// We can't simply get length of todos list
// because what if user removed something in between?
const todosAdded = appState.todosAdded + 1;
if (todosAdded === 4 && appState.todos[appState.todos.length - 1] === 'foobar') {
return {
...appState,
showEasterEgg: true,
todosAdded
};
} else {
return {
...appState,
todosAdded
};
}
}
const reducer = (appState = { todos: [], todosAdded: 0, showMessage: false }, { type, payload }) = {
switch (type) {
case 'ADD_TODO':
return businessPredicate({
...appState,
todos: [...appState.todos, payload]
});
default:
return appState;
}
}
Richard Feldman once said that keeping a list of actions in your app state is idiomatic Elm approach. This may work for simple use cases, but imagine that the business requirement for the feature would be much more complex (eg. 100 steps before showing the text), it doesn't really scale well, and most importantly, it pollutes the application state with data which is not strictly required for rendering the UI. This effectively means that we turn the state into the source of truth.
Application state is just a projection - a projection which can be used to render the UI. Application state is required for rendering, but not for business logic. From the business logic perspective, a list of all the events is what matters. This leads us to the fact that Actions can't simply map 1:1 to UI interactions (clicks, API responses, mouse moves). We need to transform those user interactions into Domain Events so that the Event log becomes the source of truth. Because Domain Event is not click, domain event is that item has been added to the basket.
I really like the idea of redux-saga
(or redux-observable
), which allows us to implement the necessary business logic in Sagas. We can simply turn actions into small domain-related events so that reducer is really just a projector of those events into State, which can then be consumed by the View layer to render something meaningful.
The author of redux-loop
advocates his idea by saying:
Many other methods for handling effects in Redux, especially those implemented with action-creators, incorrectly teach the user that asynchronous effects are fundamentally different from synchronous state transitions. This separation encourages divergent and increasingly specific means of processing particular types effects. Instead, we should focus on making our reducers powerful enough to handle asynchronous effects as well as synchronous state transitions. With redux-loop, the reducer doesn't just decide what happens now due to a particular action, it decides what happens next. All of the behavior of your application can be traced through one place, and that behavior can be easily broken apart and composed back together. This is one of the most powerful features of the Elm architecture, and with redux-loop it is a feature of Redux as well.
But is finite state machine really a good choice for dealing with asynchronicity? Most long-lived transactions will probably become stateful at some point, and keeping the state information in the application state will turn it into business logic source of truth, which is not desirable.
The Elm Architecture is mixing asynchronous long-lived transactions with synchronous state mutations together. Believing in the correctness of this solution was one of the biggest mistakes in my front-end career. It defeats the purpose of event log as the source of truth, because the responsibility is effectively inverted and delegated to application state instead.