Exploring Unidirectional Components in Mithril (part 2— Redux)
In part 1 of this post, I described how we could morph the Mithril library into a Hyperapp lookalike to achieve unidirectional data flow. This time, I will use Redux with the reducer pattern instead, and compare the resulting code to the Hyperapp architecture.
Disclaimer: The Redux docs specifically advise against using multiple Redux stores, which is what we’re doing here. When writing real applications — listen to the docs, folks! When playing with code — break the rules a little bit — it’s where learning happens.
Need a Counter?
We’ll dive straight into the code and try to explain how exactly the details are implemented behind the scenes a bit later.
First of all, let’s take a quick look at the view function for our beloved Counter component, which we’ll reuse in both the Hyperapp and Redux architectures:
const CounterView = (vnode, { count }, actions) => (
<div>
<h1>{count}</h1>
<button onclick={actions.decrement} disabled={count <= 0}>-</button>
<button onclick={actions.increment}>+</button>
</div>
);
(if this is all new to you, now is the time to catch up with part 1 of this post)
And here are the components themselves:
Hyperapp architecture
const Counter = component({
state: {
count: 0
},
actions: {
decrement: ({ count }) => ({ count: count - 1 }),
increment: ({ count }) => ({ count: count + 1 })
},
view: CounterView
});
Redux architecture
const Counter = component({
reducers: {
count(state = 0, action) {
switch (action.type) {
case 'DECREMENT': return count - 1;
case 'INCREMENT': return count + 1;
}
return state;
}
},
actions: {
decrement: () => ({ type: 'DECREMENT' }),
increment: () => ({ type: 'INCREMENT' })
},
view: CounterView
});
If this looks sоmewhat long and verbose to you, don’t worry — we’ll make it a bit shorter later.
Parallels and opposites
Both the Elm architecture (used in Hyperapp) and Redux have similar goals — reduce complexity, increase code clarity, and avoid direct state manipulation.
Hyperapp architecture
The fundamental difference I can see is that in Hyperapp, actions are more complex functions that handle the logic and return all changes across the state.
I find this approach to be particularly useful when the effects of an action are relatively well known in advance — for example, clicking the + or - button of a counter, or rendering different views depending on whether the user is logged in or out.
Redux architecture
In Redux, actions are meant to be really dumb, returning a simple object with only the minimal data for the reducer, where the real logic lies.
In addition to handling actions whose effects are well-known in advance, it also works great for the specific use case where a single action could cause various changes across the whole application.
For example, in a real-time application, receiving a notification could do all of the following:
- Update a counter in the header
- Show the number of unread notifications in the page title wrapped in parentheses, thus flashing the browser tab (think Gmail)
- Show a toast notification
- Show a browser desktop notification (if the user is on another tab)
With Redux reducers, we can dispatch an action called NOTIFICATION_RECEIVED
or USER_LOGGED_IN
, then handle the specific logic in each reducer, changing the data.
With Hyperapp, the best way I could think of is using an event bus. One of the things I love the most about Hyperapp is that it‘s really flexible and allows you to use all kinds of different patterns to write your application!
Refactoring opportunities
As always, our initial code can be made more reusable through some simple refactoring. This is where the functional-oriented approach of both architectures shines.
Hyperapp architecture
Here, we can extract the similar counter logic in a function:
const addToCount = (value) => ({ count }) => ({ count: count + value });...
actions: {
decrement: addToCount(-1),
increment: addToCount(1)
},
...
Or use an even more generic approach:
const add = (key, value) => (state) => ({ [key]: state[key] + value });...
actions: {
decrement: add('count', -1),
increment: add('count', 1)
},
...
Redux architecture
First, we can reduce the boilerplate by avoiding switch
statements or explicitly returning state
when the reducer doesn’t match the dispatched action:
const Counter = component({
reducers: {
count: reducer(0, {
DECREMENT: (count) => count - 1,
INCREMENT: (count) => count + 1
})
},
actions: {
decrement: () => ({ type: 'DECREMENT' }),
increment: () => ({ type: 'INCREMENT' })
},
view: CounterView
});
As you can see, the resulting code is somewhat shorter, easier to read, and close to the Hyperapp example. Here’s what the reducer
function looks like:
const reducer = (initialState, handlers) =>
(state = initialState, action, rootState, actions) => {
const handler = handlers && handlers[action.type];
if (handler) {
return handler(state, action, rootState, actions);
}
return state;
};
It’s basically a copy of the createReducer
function described in the Redux docs — the first parameter is the initial reducer value, and the second is an object with all actions and the resulting changes.
Then, we can create a higher level event called ADD_TO_COUNT
to utilize the Action Creator pattern:
const addToCount = (value) => () => ({ type: 'ADD_TO_COUNT', value });const Counter = component({
reducers: {
count: reducer(0, {
ADD_TO_COUNT: (count, action) => count + action.value
})
},
actions: {
decrement: addToCount(-1),
increment: addToCount(1)
},
view: CounterView
});
(actually, that’s an Action Creator Creator, but anyway)
As you can see, both architectures are conducive to breaking the state down into small, predictable, and testable functions.
Implementation
Now that we’ve seen what Mithril and Redux can do together, let’s see how this actually works behind the scenes:
const component = ({ actions, reducers, events, view }) => (vnode) => {
// Create functions which dispatch the actions to the store
const actionDispatchers = {};
Object.keys(actions).forEach((key) => {
actionDispatchers[key] = (...args) => {
store.dispatch(actions[key](...args));
};
});
// Reducer proxies are called with the state and action proxies as additional parameters
const reducerProxy = (state = {}, action) => {
const newState = {};
Object.keys(reducers).forEach((key) => {
newState[key] = reducers[key](state[key], action, state, actionDispatchers);
});
return newState;
};
// Create store with initial values from component attributes
const store = Redux.createStore(reducerProxy, vnode.attrs);
// The store conveniently redraws the view when data changes
store.subscribe(m.redraw);
return {
...events,
view: () => view(vnode, store.getState(), actionDispatchers)
};
};
Conclusion
Much like the Hyperapp-like architecture described in part 1 of this post, this one has some of the same caveats — it’s still Mithril, not serializable, not completely type safe (if you’re into TypeScript), and not battle-tested.
But most of all, like I said in the initial disclaimer, having multiple Redux stores is not advised. So don’t use this in production!
(but if you do, please let me know how it turned out, you little rebel :))
Resources
- An up to date implementation of the
component
function - A more complex component — Stopwatch
- All examples used across this series of posts