Motorcycle.js And Lessons Learned The Hard Way

11 min read

UPDATE: webpackbin has been deprecated, so links have been removed.

I have been playing with the CycleJS and it's sister project, MotorcycleJS for the past couple of weeks and I recently decided to use my goto approach for learning something new so I could gain a deeper level of understanding.

The following is the path I took when I set out to do something that I expected to be simple and it ended up being difficult. These are the steps I went through to get it "right" and to realize that once I changed my thinking, that task became simple again.

warning/disclaimer! I walk through a few code samples that are not complete or ideal solutions so I suggest you, either read the whole thing, or skip to the final code sample if you're just looking for working code.

You have been warned 😀

The sample code is using the MotorcycleJS framework. Motorcycle is very similar to CycleJS, so with some minor changes, this code can easily be ported over to CycleJS. Motorcycle uses the most.js library for observables and relies on Snabbdom for the rendering from its DOM Driver. To port this code to CycleJS, you would need to switch out core libraries and change the observables to their equivalents in RxJS.

I figured, I would start with something like this:

import Motorcycle from '@motorcycle/core'
import { div, label, input, hr, h1, makeDOMDriver } from '@motorcycle/dom'

function main(sources) {
  const sinks = {
    DOM: sources.DOM.select('.field')
      .events('input')
      .map((ev) => ev.target.value)
      .startWith('')
      .map((name) =>
        div([
          label('Name:'),
          input('.field', { attributes: { type: 'text' } }),
          hr(),
          h1('Hello ' + name),
        ])
      ),
  }
  return sinks
}

Motorcycle.run(main, { DOM: makeDOMDriver('#app') })

This creates an observable of input events and maps over those inputs, rendering an updated DOM each time with the latest input in the h1('Hello ' + name) node. If you run this code, you'll see it display characters below the input as you enter them.

Now that the reactive version of hello world is out of the way, My next thought was:

Typically, I want to fill out a form, then submit it once it's complete.

With that in mind, I made some changes to the code so that the input value wouldn't be displayed on the page until the button was clicked. I ended up with code that looked like this:

import Motorcycle from '@motorcycle/core'
import { div, label, input, hr, h1, makeDOMDriver } from '@motorcycle/dom'
import most from 'most'

function main(sources) {
  const input$ = sources.DOM.select('.field').events('input')
  const click$ = sources.DOM.select('.btn').events('click')

  const sinks = {
    DOM: click$
      .sample((in$) => in$, input$)
      .map((ev) => ev.target.value)
      .startWith('')
      .map((name) =>
        div([
          label('Name:'),
          input('.field', { attrs: { type: 'text' }, props: { value: '' } }),
          input('.btn', { attrs: { type: 'submit', value: 'Save' } }),
          hr(),
          h1('Hello ' + name),
        ])
      ),
  }
  return sinks
}

Motorcycle.run(main, { DOM: makeDOMDriver('#app') })

Here, I added a button to the rendered DOM: input('.btn', {attrs:{type:'submit',value:'Save'}}), then I pulled the sources.DOM.select('.field').events('input') bit out and assigned it to input$. I also added a stream for click events: const click$ = sources.DOM.select('.btn').events('click') With that in place, I set my DOM sink value to start with: click$.sample((in$) => in$, input$) which makes the click event sample the input value, or in other words, the value of the input at the moment the click event is emitted is passed into the rest of my observable sequence. With these changes in place, typing into the input field won't result in immediate updates to the output, but clicking the button will render it with the text that has been entered.

If you run this code, you'll probably notice that the input field still has the entered value after clicking the button and we want to clear that value out upon submission to allow additional submissions. I thought I had taken care of that by using input('.field', {attrs: {type: 'text'}, props: {value:''}}), where I declared the input with a value property of an empty string. My (incorrect) thinking here was that when the button click triggered a re-render of the view, it would apply that empty value and the input field would be left empty. That didn't work. The reason it didn't work is that my view rendered with an empty input and when it re-rendered, the empty input field in the newly generated VDOM node matched the previous state of that node in the VDOM, so Snabbdom (correctly) ignored that node and rendered only the changes it could detect.

From here, I (incorrectly) latched on to the idea that I needed to somehow force or trick Snabbdom into applying my empty value. So I did some experimentation and came up with this:

import Motorcycle from '@motorcycle/core'
import { div, label, input, hr, h1, makeDOMDriver } from '@motorcycle/dom'
import most from 'most'

function main(sources) {
  const input$ = sources.DOM.select('.field').events('input')
  const click$ = sources.DOM.select('.btn').events('click')

  const sinks = {
    DOM: click$
      .sample((in$) => in$, input$)
      .map((ev) => ev.target.value)
      .startWith('')
      .map((name) =>
        div([
          label('Name:'),
          input(`.field.${name}`, {
            attrs: { type: 'text' },
            props: { value: '' },
          }),
          input('.btn', { attrs: { type: 'submit', value: 'Save' } }),
          hr(),
          h1('Hello ' + name),
        ])
      ),
  }
  return sinks
}

Motorcycle.run(main, { DOM: makeDOMDriver('#app') })

The big change here is in this line: input(.field.${name}, {attrs: {type: 'text'}, props: {value:''}}). The .${name} portion is the key. Essentially, adding this changed the identifier of the input field at each change in our name (still triggered by sampling the input in the button click). This "worked" but with multiple problems.

  1. I ended up applying an unnecessary class to the input element. This runs the risk that the class could exist in the application's stylesheet and that could lead to unintended side-effects.
  2. If the input value is the same on two submits in a row, the field won't be re-rendered (because the added classname would match) and the input won't be cleared out.
  3. The props:{value:''} piece of this node declaration never actually does anything. What actually happens, is that rendering with a new query selector causes Snabbdom to see it as a new element and render a new input, which defaults to an empty value anyway.
  4. Possibly the biggest issue with this approach is that the code doesn't really show intent. Without understanding the reason for the hack or what is really happening under the hood (see problem 3), it isn't really clear what should happen here just by reading the code.

Not happy with that approach, I shared it in the motorcycle gitter channel, looking for some advice. I got a great tip to clean up my use of sample by switching to sampleWith, but I still had some work to do to handle the DOM update. So I tinkered around a bit more, still focused on getting Snabbdom to behave differently (still wrong). I came up with this:

import Motorcycle from '@motorcycle/core'
import { div, label, input, hr, h1, makeDOMDriver } from '@motorcycle/dom'
import most from 'most'

function main(sources) {
  const input$ = sources.DOM.select('.field').events('input')
  const click$ = sources.DOM.select('.btn').events('click')

  const sinks = {
    DOM: input$
      .sampleWith(click$)
      .map((ev) => ev.target.value)
      .startWith('')
      .map((name) =>
        div([
          label('Name:'),
          //Reset the text field value on each render
          input('.field', {
            attrs: { type: 'text' },
            hook: {
              update: (o, n) => (n.elm.value = ''),
            },
          }),
          input('.btn', { attrs: { type: 'submit', value: 'Save' } }),
          hr(),
          h1('Hello ' + name),
        ])
      ),
  }
  return sinks
}

Motorcycle.run(main, { DOM: makeDOMDriver('#app') })

In this code, I took advantage of Snabbdom's hook mechanism to apply an empty value on the node's update event. Adding hook:{update: (o, n) => n.elm.value = ""} meant that Snabbdom would reset the input element's value on every render.

Feeling victorious, I shared my updated code on gitter. @TylorS responded with:

Yeah, hooks to me are sort of a last resort IMO

Because they allow you to drop down into doing things imperatively

Which is totally not what Cycle or Motorcycle is all about

This feedback forced me to think about my approach and ultimately lead me to realize I was attacking this problem from the wrong angle. In fact, I was trying to solve the wrong problem because I hadn't taken the time to step back and realize what the actual problem was.

The problem was that in a framework like Cycle or Motorcycle, the view is a function of the application's state and I was not treating it that way.

I took a step back here and realized that the input value and the display of that input value at the time the button is clicked are ultimately two individual pieces of my application state. They are related since the input field gathers the display value, but they needed to be handled separately for the purposes of rendering. Once I realized this, the fix was pretty straightforward. With these points in mind, let's walk through the code (the full code sample is shown below):

The input field is central to these pieces, so I broke that out into a separate stream constant. I also grabbed the button's click event as a stream:

const input$ = sources.DOM.select('.field').events('input')
const click$ = sources.DOM.select('.btn').events('click')

From there, I used the input$ as a base and created the stream to represent the application state on each input event

const nameChange$ = input$.map((ev) => ({
  displayName: '',
  inputVal: ev.target.value,
}))

Here is the nameChange$ code in marble diagram format:

nameChange$ marble diagram

Notice that instead of mapping to the value of the input, I'm mapping to an object with two properties. One property (displayName) will be used for the value of the name in the rendered h1 and the other (inputVal) contains the input value.

Then I use the click$ to sample the input$ and assign the resulting stream to save$

const save$ = input$
  .sampleWith(click$)
  .map((ev) => ({ displayName: ev.target.value, inputVal: '' }))

Here is a visual of the save$ code:

save$ marble diagram

Notice that I am mapping to that same object structure I used to handle changes to the input field. This time, however, I am taking the sampled input field value and assigning it to displayName so it will appear in the rendered h1 and setting the inputVal property to an empty string.

This all gets tied together by merging the nameChange$ and the save$ streams into a value I've named state$.

const state$ = nameChange$
  .merge(save$)
  .startWith({ displayName: '', inputVal: '' })

Here is the state$ code in marble diagram format:

state$ marble diagram

Notice that I have added .startWith({displayName:'', inputVal:''}) to the state$. I need this because I am going to use the object structure {displayName:<VALUE>, inputVal:<VALUE>} to render the UI. Without starting with a blank value for the displayName property, the initial rendering will put undefined in the h1 output. The inputVal property could have been left out here, but I am using it to be consistent and so the code properly communicates my intent.

With all of the observables setup and being funneled into state$, it's just a matter of mapping over it to generate the UI:

const sinks = {
  DOM: state$.map(function renderState(state) {
    return div([
      label('Name:'),
      input('.field', {
        attrs: { type: 'text' },
        props: {
          value: state.inputVal,
        },
      }),
      input('.btn', { attrs: { type: 'submit', value: 'Save' } }),
      hr(),
      h1(`Hello ${state.displayName}`),
    ])
  }),
}
return sinks

The Finished Code

When it is all pulled together, the final result looks like this:

import Motorcycle from '@motorcycle/core'
import { div, label, input, hr, h1, makeDOMDriver } from '@motorcycle/dom'
import most from 'most'

function main(sources) {
  const input$ = sources.DOM.select('.field').events('input')
  const click$ = sources.DOM.select('.btn').events('click')

  const nameChange$ = input$.map((ev) => ({
    displayName: '',
    inputVal: ev.target.value,
  }))

  const save$ = input$
    .sampleWith(click$)
    .map((ev) => ({ displayName: ev.target.value, inputVal: '' }))

  const state$ = nameChange$
    .merge(save$)
    .startWith({ displayName: '', inputVal: '' })

  const sinks = {
    DOM: state$.map(function renderState(state) {
      return div([
        label('Name:'),
        input('.field', {
          attrs: { type: 'text' },
          props: {
            value: state.inputVal,
          },
        }),
        input('.btn', { attrs: { type: 'submit', value: 'Save' } }),
        hr(),
        h1(`Hello ${state.displayName}`),
      ])
    }),
  }
  return sinks
}

Motorcycle.run(main, { DOM: makeDOMDriver('#app') })

This was a relatively simple task, made (temporarily) difficult by the shift in mindset needed to work in frameworks like these. Going through this process the hard way was helpful for me, and writing it up has really helped me solidify the way I will look at problems like this as I continue to work with MotorcycleJS, CycleJS and observables in general.

I hope you found this helpful and it prevents you from going through similar pain.

Update:

Based on a comment on this post, I made some code updates and wrote about the changes and how I arrived at them.