In this brief note we compare some aspects of Calmm and Redux. We assume that the reader is already familiar with Redux. We also assume that the reader has basic familiarity with Calmm. Our goal here is to gain a deeper understanding on how they are related.
From our point of view, the central concept of Redux is the concept of a
Store. As is well known, a store
can be implemented with typical event stream combinator libraries in just a few
lines of code. Here is a minimalistic
createStore
lookalike using
Bacon:
const createStore = (reducer, initial) => {
const bus = Bacon.Bus()
const store = bus.scan(initial, (state, action) => reducer(state, action))
store.dispatch = action => bus.push(action)
return store
}
The way the above works is that we construct a
Bus
for sending actions to the
store we are creating. We then create a property, using
scan
, by starting from
the initial
value and using the reducer
to compute a new state after each
message or action that comes from the bus. It is really quite simple.
Types can be great for
understanding programs. Let's see. We could give createStore
the following
type:
createStore :: (state -> action -> state) -> state -> IO (Store action state)
What this means is that createStore
takes two arguments. The first argument,
the reducer, is a function from a state and an action to a state. The second
argument is the initial state. The result is a store that contains state of
type state
and understands actions of type action
.
The Store
type constructor also comes with various actions, but at this stage
we are mainly interested in just one, dispatch
, which we could type as
follows:
dispatch :: Store action state -> action -> IO ()
What this means is that dispatch
takes a store that understands actions of
type action
and an action of said type and returns nothing. In other words,
dispatch
is only used for the side-effect.
Now, in the above, the action
type could be anything, but, in Redux, actions
are supposed to be just data:
Actions. So, createStore
is a
higher-order function and
dispatch
is plain or first-order data.
Let's then turn our attention to Calmm and to the Atom concept. Similarly to stores of Redux, a minimalistic lookalike Atom can be implemented in just a few slices of Bacon:
const Atom = initial => {
const bus = Bacon.Bus()
const atom = bus.scan(initial, (state, action) => action(state))
atom.modify = action => bus.push(action)
return atom
}
Didn't we just look at this function? Not exactly. How is this actually different from a store?
Like a store, an atom takes an initial state and creates a bus for messages. As can be read from the code above, and unlike with a store, the messages that an atom takes are supposed to be functions that compute a new state given the current state.
Let's then take a look at the types like we did with Redux. First the type of
Atom
(we are slightly abusing Haskell lexical grammar here):
Atom :: state -> IO (Atom state)
Then the type of modify
:
modify :: Atom state -> (state -> state) -> IO ()
From the types we can directly read that Atom
is a first-order function and
modify
is a higher-order function. So, stores and atoms have their
higher-order and first-order parts exchanged. This is a fundamental difference
between stores and atoms.
Before going to the gist of this note, let's see how we can can implement atoms in terms of stores and vice verse.
First here is createStore
implemented using Atom
:
const createStore = (reducer, initial) => {
const atom = Atom(initial)
atom.dispatch = action => atom.modify(state => reducer(state, action))
return atom
}
And here is Atom
implemented using createStore
:
const Atom = initial => {
const store = createStore((state, action) => action(state), initial)
store.modify = action => store.dispatch(action)
return store
}
Nice symmetry!
The astute reader noticed, however, that this latter implementation of Atom
in
terms of createStore
violated the idea of stores that actions are just data.
As mentioned previously the key difference between stores and atoms is that their higher-order and first-order parts have been flipped. Innocuous as that may seem, it makes them fundamentally different. It turns out that stores, or more accurately reducers, are composable, while atoms are decomposable.
Indeed, Redux comes with the
combineReducers
function
for combining reducers. Abusing a Haskell -style notation, we could give
combineReducers
the following type:
combineReducers :: {p1: s1 -> a1 -> s1,
p2: s2 -> a2 -> s2} ->
{p1: s1, p2: s2} -> Either a1 a2 -> {p1: s1, p2: s2}
Again, the astute reader noticed that this is, in fact, not the actual type of
combineReducers
, because combineReducers
does not change the type of
actions. We do this, because it is a key that makes reducers composable: by
using a disjoint union of actions, we can route actions precisely.
The actual combineReducers
function passes actions to all the reducers. In
some cases this might be what you want, but it doesn't compose.
Now, it is not difficult to imagine a library of reducer combinators. An experienced functional programmer should be able to whip up one in no time. For example, one could write a combinator for arrays. It could have a signature like this:
arrayReducer :: (s -> a -> s) -> [s] -> (a, Integer) -> [s]
Just like with our changed combineReducers
function, we extend the action type
to make it possible to route actions to a precise target.
But the point is that by following the structure or algebra or logic of types we can compose reducers to make reducers for arbitrarily complex nested states. We could even write reducer combinators that allow reducers to be composed in ways that do not strictly follow the construction of the data, but rather follow some properties computed from data.
The logical next step would be to explain that atoms can be decomposed or sliced using lenses, but we've already read about it in the Combining Atoms and Lenses section of the Calmm introduction.
Redux reducers are composable.
Calmm atoms are decomposable.
Redux Stores and Calmm Atoms are related, but fundamentally different. Redux stores can be instantiated with composable reducers. Atoms can be decomposed using lenses and lenses can be composed. Out of the box, Redux provides only a single reducer combinator. Calmm takes the idea of composability and decomposability seriously and provides a library of composable lenses to effectively decompose state to components making the components themselves composable.