MotorcycleJS - More Lessons Learned

6 min read

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

In my previous post, I walked through the missteps that lead me to adjust my thinking about how to handle data in MotorcycleJS and CycleJS.

One of the readers of that post was kind enough to point out that the way the final code sample in that post was written wasn't ideal. In that example, When you enter text and click the button, the input field is cleared (as expected) and the value is displayed on the page (also expected), but as soon as you start to type into the input again, it clears out the previous value on the page output (unexpected).

the helpful comment

I had originally planned to just append some follow up to the previous post, but I thought the changes I made were significant enough to warrant separation. Like last time, this was a bit of a journey... luckily, this one was a bit shorter 😀.

So my first pass at making a simple change was to change the way I created the state$ to use combine instead of merge that changed look like:

- const state$ = nameChange$.merge(save$)
+ const state$ = nameChange$
+  .combine((name, save) => Object.assign({}, name, save), save$)
+  .startWith({displayName:'', inputVal:''})

The problem with this approach was that clearing out the input field no longer worked. Merging objects resulted in always maintaining the truthy value for the input, so the blank input value was never used. After trying a couple of random experiments, I decided to once again, step back and look at the problem differently. The problem was that I needed to know which action had occurred so I could then determine what needed to happen to my state. That immediately lead me to think Elm Architecture (what Redux is based on for those who may not be familiar).

With that in mind, I reached for most's scan and made some changes to the code.

Following the model that Elm and Redux use, I created a few constant values to represent my actions:

const DEFAULT = 'defaultAction'
const INPUT_VAL = 'inputVal'
const SAVE_VAL = 'saveVal'

along with a value to represent my default state object:

const defaultState = { displayName: '', inputVal: '' }

With those in place, I reassigned the merge sink to actions$ and modified the startWith value to pass an object with a type property of DEFAULT:

const actions$ = nameChange$.merge(save$).startWith({ type: DEFAULT })

And setup the new version of state$:

const state$ = most.scan(
  function updateState(state, action) {
    switch (action.type) {
      default:
        return defaultState
    }
  },
  defaultState,
  actions$
)

scan works like a reduce, but it works with values as they arrive over time rather than waiting for the stream to end. The first argument here is a reducer function. It accepts the accumulator (state in this case) and the new value (action). The second argument defaultState is the starting value for the accumulator, and actions$ is the stream that will be sending updates to the scan operation. Whatever we return from scan will be wrapped in an observable and we can use map to render our output just like in the previous version.

I should point out that the DEFAULT constant here isn't really used in the reducer. I just let it fall through to the switch statement's default handler. Passing it around is about being consistent and communicating intent in the code.

With the basic plumbing in place, I had to modify the nameChange$ and save$ mappings to return an object that included my action type. Here I use the constants that were defined earlier.

const nameChange$ = input$.map((ev) => ({
  type: INPUT_VAL,
  data: ev.target.value,
}))

const save$ = input$
  .sampleWith(click$)
  .map((ev) => ({ type: SAVE_VAL, data: ev.target.value }))

With the values coming out of those streams updated to work in the context of the reducer function I used in scan, I updated the reducer to handle those specific actions:

const state$ = most.scan(
  function updateState(state, action) {
    switch (action.type) {
      case INPUT_VAL:
        return Object.assign({}, state, { inputVal: action.data })
      case SAVE_VAL:
        return { displayName: action.data, inputVal: '' }
      default:
        return defaultState
    }
  },
  defaultState,
  actions$
)

For changes to the input field, I am using Object.assign to merge the updated input value state with whatever state is already there. For the save$ action, I am explicitly defining the entire state object so the blank value isn't ignored in a merge situation. If this application had more complex state, then this would need to be adjusted to accommodate other values.

This is what the complete code for the updated implementation of this looks like:

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

const DEFAULT = 'defaultAction'
const INPUT_VAL = 'inputVal'
const SAVE_VAL = 'saveVal'
const defaultState = { displayName: '', inputVal: '' }

function main(sources) {
  const input$ = sources.DOM.select('.field').events('input')
  const click$ = sources.DOM.select('.btn').events('click')
  const nameChange$ = input$.map((ev) => ({
    type: INPUT_VAL,
    data: ev.target.value,
  }))

  const save$ = input$
    .sampleWith(click$)
    .map((ev) => ({ type: SAVE_VAL, data: ev.target.value }))

  const actions$ = nameChange$.merge(save$).startWith({ type: DEFAULT })

  const state$ = most.scan(
    function updateState(state, action) {
      switch (action.type) {
        case INPUT_VAL:
          return Object.assign({}, state, { inputVal: action.data })
        case SAVE_VAL:
          return { displayName: action.data, inputVal: '' }
        default:
          return defaultState
      }
    },
    defaultState,
    actions$
  )

  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 is a learning process for me, so if you see something here that doesn't make sense, or you can show me a simpler or more elegant approach, I would love to hear from you. I am always willing to make adjustments and to share the next lesson that comes out of this.

I hope you found this helpful in some way.