Skip to content

Latest commit

 

History

History
206 lines (162 loc) · 8.01 KB

redux-vs-calmm.md

File metadata and controls

206 lines (162 loc) · 8.01 KB

Redux vs Calmm

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.

Stores with Bacon

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.

Atom with Bacon

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.

Conversions

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.

Composing and Decomposing state

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.

Summary

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.