Functions as Redux actions

I’ve been building several toy applications with Redux to gain more experience in structuring Redux apps. I recently played around with this idea of dispatching functions into a Redux store.

To make it clear: I’m not using redux-thunk or anything like it; I’m just dispatching functions right into the store. I found that it gives my code a better structure and reduces a lot of boilerplate.

Before someone brings up matters about serializability, let me get this out first: with this approach,

  • You don’t lose serializability of store state. Your app’s state is still plain old JSON-serializable JavaScript objects.
  • Hot reloading and time traveling still works. When you change the model’s code, past actions can be replayed against the new model’s code.
  • You do lose action serializability, which, compared to store state serializability, is not a big deal in my opinion. I’ve not encountered the need for it yet.

Problem: Redux doesn’t like it.

Redux does’t like it when you dispatch a non-object into a store. So I’m going to cheat by dispatching only one action type MESSAGE.

store.dispatch({
type: 'MESSAGE',
message: function () { ... }
})

For convenience, I’ll create a middleware which will wrap the function in a MESSAGE action:

const sendMiddleware = store => next => action => next(
typeof action === 'function'
? { type: 'MESSAGE', message: action }
: action
)

To make it unambiguous, I’ll call the function I dispatched into the store a ‘message.’ Now let’s build a simple application.


The Counter App

Every reactive app must have a counter demo. In normal Redux apps, lots of the logic would go into reducers. I’ll instead create a ‘model,’ which represents the state and the logic to update it.

const CounterModel = {
getInitialState: () => 0,
increment: () => (state => state + 1),
decrement: () => (state => state - 1)
}

The getInitialState() method returns the initial state of the counter, while other methods returns a state-updating function.

And here’s the view. Notice how I dispatched a function into the store.

const Counter = ({ state, dispatch }) => <span>
<input type='button' value='-' onClick={() =>
dispatch(counter => counter.decrement()) // <--
} />
<strong> {state} </strong>
<input type='button' value='+' onClick={() =>
dispatch(counter => counter.increment()) // <--
} />
</span>

Now, how do I glue them together with Redux?

I need to create a reducer.

const init = Model => Model.getInitialState()
const reducer = (state = init(CounterModel), action) => (
action.type === 'MESSAGE'
? action.message(CounterModel)(state)
: state
)

If a function (a message) is dispatched to the store (identified by the action type MESSAGE), its reducer executes the message and returns the updated state of the store.


Dissecting the reducer…

The above reducer code may look cryptic. To see what is going on, let’s consider what happens when I dispatch the ‘increment’ message:

dispatch(counter => counter.increment())

This is what gets dispatched to the reducer (the message is in bold):

{ type: 'MESSAGE', message: counter => counter.increment() }

Since the action type is MESSAGE, the reducer will execute this branch:

action.message(CounterModel)(state)

Which evaluates to this:

(counter => counter.increment())(CounterModel)(state)

Then the CounterModel will be injected into the message, and then evaluated:

(CounterModel.increment())(state)

Calling the increment() method on CounterModel results in this state-updating function:

(state => state + 1)(state)

It is then invoked with the counter’s current state in order to obtain the next state of the counter:

state + 1

Note that the message simply informs the recipient about the action that has taken place without mentioning the CounterModel. “To whom it may concern, the user wanted to increment the counter,” says the message.

Because CounterModel is behind the reducer, this means it can be hot-reloaded along with the reducer. When CounterModel is changed, the store can replay all the messages using the new reducer, and subsequently the new CounterModel. This wouldn’t work if CounterModel is mentioned directly in the dispatch call.


Multiple Counters App

With this approach, you can easily compose pieces together. Let’s say I want multiple counters, where new counters can be created and existing ones can be removed.

First, the model:

const CountersModel = {
getInitialState: () => [
CounterModel.getInitialState()
],
add: () => state => [
...state, CounterModel.getInitialState()
],
remove: index => state => (state
.filter((_, i) => i !== index)
),
sendTo: (index, message) => state => Object.assign([ ...state ], {
[index]: message(CounterModel)(state[index])
})
}

Most methods are pretty straightforward. The `sendTo` method also takes a (nested) message and forwards it to the correct counter.

Next, the view:

const Counters = ({ state, dispatch }) => <ul>
{state.map((counterState, index) => <li key={index}>
<Counter
state={counterState}
dispatch={message =>
dispatch(counters => counters.sendTo(index, message))
}
/>
<input type='button' value='x' onClick={() => {
dispatch(counters => counters.remove(index))
}} />
</li>)}
<li>
<input type='button' value='Moar counters!' onClick={() => {
dispatch(counters => counters.add())
}} />
</li>
</ul>

You can see that the code for both <Counter /> and CounterModel does not need to be changed at all. We just send a different `dispatch` function that redirects the message to the counter at a specified index. This is similar to the original Elm architecture.

Finally, we need to change the reducer to use the new model.

const reducer = (state = CountersModel.getInitialState(), action) => (
action.type === 'MESSAGE'
? action.message(CountersModel)(state)
: state
)

In my opinion, there are several benefits gained from this approach:

  • There is no need to define constants for each action type. The action (message) is programmed directly to the model’s interface.
  • The dispatch call can be type-checked. Just configure the type checker to check that you always dispatch a function of type (CountersModel → CountersState → CountersState) to the store.
  • The flow of each action is made explicit. In normal Redux, most reducers respond to the same ‘global’ set of action types, which leads to a convention where you prefix the action type with app name to avoid unexpected conflict. With this approach you can only use methods that are relevant to the model you’re given.
  • The CountersModel doesn’t need to handle each type of CounterModel’s actions. Instead, we simply have CountersModel forwarded the message to individual CounterModel.

Mix updeep in

Recall that the type of the methods in the model is this:

((…params) → (ModelState → ModelState))

updeep is a library that helps generating a state-updating function. For example, this:

import u from 'updeep'
const incrementPlayCount = u({
stats: {
playCount: x => x + 1
}
})

Is roughly equivalent to this:

const incrementPlayCount = state => ({
...state,
stats: {
...state.stats,
playCount: (x => x + 1)(state.stats.playCount)
}
})

You can see that with `updeep` the state-updating function can be made very concise and clear. Therefore, the CountersModel can be shortened into:

const CountersModel = {
getInitialState: () => [
CounterModel.getInitialState()
],
add: () => state => [
...state, CounterModel.getInitialState()
],
remove: index => u.reject(
(_, i) => i === index
)
,
sendTo: (index, message) => u({
[index]: message(CounterModel)
})

}

I found it very pleasant to write Redux apps this way. If I dispatched a wrong action, I immediately receive an error message saying ‘counters.remvoe is not a function’ (instead of my action being ignored by my reducers).

I’m also still able to enjoy the benefits of time traveling and hot reloading. (Try the demo.)


How about async actions?

Using this approach, I don’t have a notion of ‘async actions.’ Instead, I make sure everything that goes to the store is pure and synchronous, and everything else I treat them as an I/O, which I put in a separate I/O module.

Instead of dispatching an async action to a store using redux-thunk:

dispatch(uploadFile(e.target.files[0]))

I simply call the I/O function, giving it access to the store:

uploadFile(e.target.files[0], store)

I simply tell the I/O module ‘upload this file, and inform the store as you do it, because the user wants to know your progress!’ This way I made sure that all the I/O code is totally separated from the rest of the application.


So far, I’ve tried this approach with two different apps.

First, I tried this with an application development environment software, wonderstudio, that I made for the Static Showdown 2016 hackathon.

It resulted in more concise code and allowed me to code faster. Here’s the model’s source code and the corresponding specs. Without having to construct action objects, I think the tests look very clean. And here’s the IO module.

Another app is a simple web frontend for an experimental radio station I set up to broadcasts songs from rhythm games.

Here’s AppModel’s code. I try to make this app as small as possible, so I’m using Preact instead of React, and as much as vanilla JS as much as I can bear with. That’s why I did not use Updeep. The IO module downloads data from the station, sends notifications, and interact with the localStorage. The store maintains the play history.


So that’s it. I wouldn’t say this is a better way to write Redux apps, but so far it worked very well for me. The problem is that I haven’t tried it with larger apps with more complex data or I/O needs.

Perhaps I may realize the need for serializable actions. Perhaps there will be a more elegant way while keeping everything serializable. Perhaps I need to experiment with it more. ;)

I’ve extracted the functions sendMiddleware and createReducer into an npm package, redux-send.

Thanks for reading!

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.