Elm Architecture with jQuery

Back to Basics

Stefan Oestreicher
JavaScript Inside

--

In my last two posts I showed how we can apply the Elm architecture pattern using React. While the pattern is quite simple my implementation is not necessarily easy to grok and I think I made my examples needlessly difficult to understand so in this post I’m going to try to explain things a little better. I’m also going to stick to simple jQuery to construct DOM elements, there won’t be any React in this post.

The Elm Architecture

The basic idea of the Elm architecture is a component that defines three things: a model, an update function and a view function. The view function takes the current model and returns a DOM representation. It also has some way to trigger a model update. In Elm the view function takes a Signal.Address which is passed to e.g. onClick together with an action value. The possible action values are defined by the component itself. This action value is sent to the given Signal.Address which eventually triggers an update. The update function takes the current model value and the action value and executes the action, returning a new model value. With the new model value the view function is called again to return a DOM representation corresponding to this new model value.

Yet Another Counter

Let’s revisit the most basic example, the simple counter. Our model is just a simple number. There are two actions that we need for this component: Increment and Decrement. We can use any value that we want to represent our actions. To keep it simple, let’s just use string constants:

// our two actions
const Inc = 'inc'
const Dec = 'dec'

The update function is pretty straightforward too:

const update = (model, action) => {
switch(action) {
case Inc: return model + 1
case Dec: return model - 1
default: return model
}
}

Now the view is where things get interesting because we need some way to trigger an update, i.e. some way to dispatch our actions. But before we do that let’s look at how it works with a static view:

const view = model => $(
'<div>'+
'<button>+</button>'+
'<span>'+model+'</span>'+
'<button>-</button>'+
'</div>'
)

Obviously we’re just creating a jQuery element with a span that contains the current model value and buttons to increment and decrement. But of course the buttons can’t do anything right now. However, before we fix that, let’s actually get this running first. What we’ll do is create a function that takes model, view and update as well as a jQuery element which we’ll use as root node.

const mount = ({model, update, view}, $root) => {
const $dom = view(model)
$root.empty().append($dom)
}

Now we can use it like this and our component will be rendered:

mount({model: 0, update, view}, $('#app'))

Great. So now we need a way to get our buttons to work. Right now we just render the initial value and that’s it. To fix that we’re going to define a dispatch function which we’ll pass to the view function. We’ll call it signal. So our view function becomes:

const view = (signal, model)=> $('<div/>').append(
$('<button>+</button>').on('click', signal(Inc)),
$('<span>'+model+'</span>'),
$('<button>-</button>').on('click', signal(Dec))
)

As you can see we just call signal with the action value that we want to dispatch and use the return value as event handler. So the dispatch function has to take an action value and return a callback.

const signal = action => () => {
// todo
}

If this is confusing because of the arrow functions this is just a function returning a callback:

function signal(action) {
// return a callback that we'll use as event handler
return function() {
// todo
}
}

Now once the callback is executed we want to trigger an update and render with the new model value. Therefore we have to define this dispatch function inside our mount function. We also have to keep the current model state.

const mount = ({model, update, view}, $root) => {

// initial state
let state = model

// dispatch function takes action, returns callback
const signal = action => () => {
// inside the callback we...
// ...update the state
state = update(state, action)

// ...and rerender
const $dom = view(signal, state)
$root.empty().append($dom)
}

// initial render
const $dom = view(signal, state)
$root.empty().append($dom)
}

So we initialize the state with the initial model value and when the callback is executed we just update the state to the new model value which we get by calling update with the current state and whatever action we got. And then we just call the view function and update the DOM.

And that’s pretty much it.

You can see the complete code for this example here.

Nested Components

I think this is the part where it got pretty confusing in my previous post. Let’s give it another try by taking a look at the counter pair example.

The model of our counter pair will have a top and bottom property which will hold the model value of the corresponding counter. The component itself will only have one action to reset both counters. However we also need to route the counter actions to the right component.

To understand why let’s try a naive implementation of the view function first:

const view = (signal, model) => $('<div/>').append(
Counter.view(signal, model.top),
Counter.view(signal, model.bottom),
$('<button>Reset</button>').on('click', signal(Reset))
)

For now we’ll just have one action to reset the counters:

const Reset = 'reset'const update = (model, action) => {
switch (action) {
case Reset: return {top: 0, bottom: 0}
default: return model
}
}

Our pair component also has a simple init function to initialize the model:

const init = (top, bottom) => ({top, bottom})

And we use the mount function the same as before, just with the counter pair model, update and view:

mount({model: init(23, 42), view, update}, $('#app'))

This works fine when you click the reset button. But it will do nothing when you click the increment or decrement buttons. That’s because our dispatch function calls the update function of the counter pair but with a counter action. For example:

update({top: 23, bottom: 42}, 'inc')

We can’t handle this in our update function because we no longer know on which counter the action should be applied.

const update = (model, action) => {
switch (action) {
case Reset: return {top: 0, bottom: 0}
case Counter.Inc:
// Which counter should we update?
// Nope, this can't work.
// And even if we knew, it's a terrible idea
// because we shouldn't have to know how Counter
// represents its actions.
break
default: return model
}
}

What we really need is some kind of action that wraps a counter action and knows which counter to update. First of all let’s use plain objects instead of strings to represent our counter pair actions:

const Reset = ({type: 'reset'})const update = (model, action) => {
switch (action.type) {
case 'reset': return {top: 0, bottom: 0}
}
}

I’m just using strings directly for the action types in this example, in reality you’d probably want to use constants instead.

Now we need a way to create an action that wraps a counter action and knows which counter it should apply to. We can use a simple function that takes a counter action and creates a counter pair action:

const Top = action => ({
type: 'forward',
prop: 'top',
forward: action
})

Then we can easily work with this in our update function:

case 'forward':
return {
...model,
[action.prop]: Counter.update(
model[action.prop],
action.forward
)
}

The only thing that needs to happen for this to work now is that the signal function that we pass to the Counter view function needs to call our action wrapper function first. We could do it manually like this:

Counter.view(action => signal(Top(action)), model.top)

But this is really just function composition so this is equivalent to:

Counter.view(compose(signal, Top), model.top)

You can just use compose from ramda, lodash etc. or define your own. For this example let’s just use our own. We can name it forward to make the code look more like the Elm counterpart:

const forward = (f, g) => x => f(g(x))

Then our complete view function looks like this:

const view = (signal, model) => $('<div/>').append(
Counter.view(forward(signal, Top), model.top),
Counter.view(forward(signal, Bottom), model.bottom),
$('<button>Reset</button>').on('click', signal(Reset))
)

And that’s it.

The complete code for this example can be found here.

Conclusion

I hope these examples are less confusing than in my previous posts. I’d appreciate any feedback. In case you have any questions just leave a comment or contact me on Twitter.

My previous posts about this topic:

--

--