We Don't Need No Stinkin' Frameworks: Writing Web Apps with Bacon.js and virtual-dom

Part 1: A Survival Guide for Backend Developers

Hello there, this is going to be a two-part post on how to write web apps easily and even joyfully, without spending tons of time trying to master some complex front-end framework. Let me start with a disclaimer: I work mostly on web services and REST APIs and venture onto the client side only out of necessity. Things like CSS scare me immensely and the prospect of writing UIs doesn't thrill me. I tried AngularJS and didn't find it particularly compelling; it is a complex framework with a steep learning curve.

If you are like me, I am almost sure you will enjoy the minimalistic approach I present here. But even if you are a seasoned AngularJS developer, there is something here for you too, since we are going to talk about Bacon.js and applying functional reactive programming (FRP) to UI development. Even in an Angular-based application, Bacon.js can help keep communication with the server manageable (one of my colleagues has done exactly that). I think the reactive programming model is the most sane way of dealing with UI, and it is very satisfying to see it getting some attention in the world of web development. So let's begin!

No promises, just bacon!

Bacon.js is described as "A small functional reactive programming library for JavaScript". I am not going to write an introduction to Bacon or even FRP. First of all, Bacon has a good documentation. Secondly, there is a lot of excellent information about FRP available online; one that I particularly enjoyed was the Elm whitepaper.

One of my colleagues who is a functional programming aficionado strenuously objects that Bacon.js is neither functional nor even reactive (a feeling that other hardliners appear to share), so in order to assuage any pedants in the audience I will refer it henceforth as "stream-based programming". In any case, it helps to understand Bacon when one thinks in terms of streams and flows. This brings a sense of time into your programming; a flow doesn't exist without the passing of time, right? That brings us to the dreaded subject of a program state. It seems like the main concern of framework developers is to provide a sexy wrapper for application state and to make local and remote synchronization of that state as painless as possible. Well guess what: with streams you don't need to keep any state at all! State is implicit and encoded in the stream flow.

Before settling on Bacon.js for my web app project, I tried a couple of others FRP-inspired solutions. My first stop was Elm. While it is a very fun language to learn and play with, at the time it didn't address the problem of composability. As a result, it was almost impossible to write anything bigger than a to-do app with it. One day I'll have to take another look, since it seems in the meantime there has been some progress made on this front.

My next stop was Hoplon, which is based on Javelin, a cell-style FRP library. And of course I should mention React and Flux, the current darlings of web developers everywhere. Of all of these options, only the React/Flux combo struck me as truly practical for real-world web development.

So why didn't I choose them? Because I was very unhappy with the idea of a global state in my applications. All these technologies have a global state object. While there are not many options for solving the data propagation/synchronization problem in the scope of Flux, there are certainly better ways in FRP (or should I say stream-based programming).

The other problem is that managing state in React becomes tedious once your application has grown to a certain size. React tells you to eschew local state and favor data dispatching only from the global state, so one has to decompose the state manually by the means of stores and pass pieces down to the component hierarchy. But in some cases, storing small pieces of local data in components (which is not advised) is the only practical way to manage local state (e.g. in the case of a dynamic widget for selecting items in a large multi-level hierarchy), so we are back to square one.

While stream-based programming with Bacon.js is not a silver bullet, it does offer more manageable code and, most importantly, a mental shift from the idea of program state. State exists with no relation to time. It is something frozen and is therefore without meaning in UI programming, which is all about time and interaction. Streams force you to think about time because instead of coding states, they require you to code transitions which in turn bring your application to a certain state. State is just a side-effect, not the end goal. In fact transitions and state are actually two ways of looking at the same thing.

But let's not theorize any further. This will all become clearer with some examples. In Part 1 (which you are now reading) I am only going to demonstrate how to fetch data from a server and trigger UI updates. But trust me, it is not much work to scale it up to full CRUD. That will come later in Part 2.

Practical Bacon with virtual-dom

Let's start with a classic simple application: yet another consumer of a search API, very much like the numerous Flickr photo examples. It uses the Rotten Tomatoes API to retrieve information about films (including suggestions for similar films).

All my examples are in CoffeeScript, whose syntax I greatly prefer to JavaScript (they should be understandable even to the great unwashed JavaScript masses). Before we start, it might be useful to have a look at the full source code for the app. You can also refer to it as we go because I am only going to outline here the important parts that make the app come alive.

The app is going to consist of 3 pieces: inputs, data model and views.

  • Inputs are instances of Bacon.Bus. We will use them to connect DOM events to the stream model.
  • Stream model - a collection of interacting streams which implicitly encode application state as time passes. One way to think of this is as a labyrinth filled with water. A push on its entrance starts a wave which propagates according to its shape, bouncing off the walls and reaching every dark corner.
  • Views - virtual-dom based chunks of DOM that are updated every time the stream model changes. Views can be seen as time-based side effects of changes propagating through the stream model.

The line between these 3 entities is a blurry one. Mostly it is here for your peace of mind and to help with structuring the code. In reality, the 3 pieces are just parts of one flow which starts with inputs, which in turn update the stream model, which in turn updates the views, which the user interacts with (i.e. more inputs)... and the whole process starts again.

Let's start with the stream model:

Search = (codeStream) ->
  get = (code) ->
    Bacon.fromPromise $.ajax { url: '/search/' + code }

  codeStream.flatMapLatest (v) -> get v

Not that impressive, is it? This is the simplest stream model one can write, and it gets the job done for our purposes.

Search() is a function that takes one argument: a stream. Each element in the codeStream is a string representing an item code we should look for. For each incoming code, we fire an Ajax request in get(). We use Bacon.fromPromise() to turn the request into a stream with just one event, the response from the server, after which the stream ends. That's all there is to it. Notice that we don't cope with asynchronicity at all. It doesn't matter when data arrives, it is enough to know that the output stream will be updated when it does.

But $.ajax returns a promise, you might be thinking. And you would be right, but Bacon comes with many adapters for feeding various stuff into streams. One of them is for promises, while others support event emitters, callbacks and jQuery events. Promises are not important here, and they could easily be replaced by a callback.

This little chunk of code is the very heart of our rendering loop. On each input event we will get an output event that can be used to drive UI updates. And this is exactly what we are going to do.

You may have heard of virtual-dom, which can be seen as a lean version of React with just the DOM diffing and updating parts.

But before we take a look at the views, we have to provide some inputs for the app. Strictly speaking, inputs are part of the data model (i.e. the user-facing part). This is correct in the abstract, but because we need some way for users to interact with the inputs, we wrap them into views and wire them up to DOM events.

Let's describe our search input field view with a searches input:

class NavBar extends BaconEvents
  constructor: () ->
    super()
    searchText = (@eventStream 'searches').map '.target.value'
    @searches = searchText.skipDuplicates().debounce(500)

  render: (code, loading) ->
    inputClass = ".ui.icon.input#{if loading then '.loading' else ''}"

    (h '.ui.menu.inverted.fixed.navbar.page.grid', [
      (h 'a.brand.item', (i18n.t 'The App')),
      (h '.item',
        (h inputClass, [
          (h 'input', {
            type: 'text',
            value: code,
            placeholder: (i18n.t 'Search by code'),
            oninput: (e) => @eventStream 'searches', e }),
          (h 'i.search.icon')]))])

Oh my, it's a whole class! Classes are not necessary here, but I decided to use classes for views since they extend the BaconEvents class. This is an ad-hoc solution for event stream support in virtual-dom based views and is inspired by react-bacon's BaconMixin:

class BaconEvents
  constructor: () ->
    @_streams = {}

  eventStream: (name, e) ->
    stream = @_streams[name]
    @_streams[name] = stream = new Bacon.Bus if stream is undefined
    stream.push e if e
    return stream

Upon first invocation of eventStream() a new Bacon.Bus will be instantiated into which we will push() DOM events.

Now we can return to our input view. Things begin to happen when the user types something into the input field. Each change will invoke @eventStream 'searches', e, causing a DOM event to be pushed into the searches stream. In the class constructor, we instantiate searches and start watching for events in it. map() extracts the search text from the events and returns a new stream with just the text in it:

searchText = (@eventStream 'searches').map '.target.value'

The next line exposes input events to the world:

@searches = searchText.skipDuplicates().debounce(500)

We use skipDuplicates() to make sure that we don't report duplicates and debounce() so we don't report too many events while the user is typing.

We are almost done. We have a stream model and we have inputs, the only missing part in the application is a view to display search results. Once we have it, we can put all 3 pieces together to make the whole application come alive:

class App extends BaconEvents
  constructor: () ->
    super()
    @_itemView = new Item
    @_suggestionView = new SuggestionList
    @suggestionClicks = @_suggestionView.clicks

  render: (data) ->
    content = []
    content.push (@_itemView.render data.item) if data?.item
    content.push (@_suggestionView.render data.suggestions) if data?.suggestions

    (h '.ui.page.grid',
      (h '.ui.grid', [
        (h '.one.column.row',
          (h '.column', (content))),
        (h '.row',
          (h '.column', [
            (h '.ui.divider'),
            (h 'span', (i18n.t '© Lovely Apps 2015'))]))]))

In the constructor we instantiate a couple of sub-views. SuggestionList exposes the clicks input, which contains events for items that were clicked by a user. You will see shortly how input streams from views can be composed in arbitrary ways to drive stream model updates. The rendering is self-explanatory; we channel pieces of data to sub-views and compose them to render the whole app view.

Putting it all together

Now we are ready to put 3 pieces together! Let's instantiate NavBar (input view) and App (output view) and import the stream model from its module:

  navbar = new NavBar()
  app = new App()
  SearchStream = require './search'

Now we take the inputs and feed them into SearchStream to produce outputs:

  searchQueries = navbar.searches.filter (v) -> v != ''
  searchQueries = searchQueries.merge (app.suggestionClicks.map '.code')
  searchResults = SearchStream searchQueries
  searchAwaiting = searchQueries.awaiting searchResults
  
  searchAwaiting.onValue (loading) ->
    ($('html,body').animate { scrollTop: 0 }, 'slow') if not loading

Here we take the navbar.searches input, filter out the empty values and merge it with app.suggestionClicks to make one stream, feed it into SearchStream, which in turn returns searchResults. The searchAwaiting stream is there for purely cosmetic reasons; it triggers small UI updates like showing a progress indicator.

The next step is to feed events from searchResults to the views. Before we do that, however, we need to deal with virtual-dom. The virtual-dom API consists of 3 functions: createElement, diff and patch. It would be good to have a uniform interface to create and update the DOM, so to make view creation more streamlined we create a small helper function:

createView = (updates) ->
  node = null
  patches = updates.diff null, (prevTree, currTree) ->
    if prevTree == null
      node = createElement currTree
      { patches: null }
    else
      { patches: diff prevTree, currTree }
  patches.map (data) ->
    if data.patches then (patch node, data.patches) else node

createView takes care of 2 things:

  • creates a view if it does not yet exist
  • updates a view on each event from the updates stream

At the core is the Bacon diff() (updates.diff). It allows us to compare the current and previous events in a stream. This is exactly what we need to create a patch for the virtual-dom view.

Now we are ready to instantiate the views. NavBar comes first:

  lastSearch = searchQueries.toProperty ''
  navbarUpdates = (searchAwaiting.combine lastSearch, (loading, code) ->
    navbar.render code, loading).startWith (navbar.render false)
  navbarNode = createView navbarUpdates
  navbarNode.onValue (navbarView) ->
    $('#navbar').replaceWith navbarView

What happens here? The NavBar view requires us to provide code and loading arguments to its render() method. But we have 2 streams. One contains only codes and the other contains only loading state. We could connect each one of them to render() separately, but then navbar would be updated in a flip-flop fashion. We would lose code on searchAwaiting updates and loading on searchQueries updates. Therefore we combine the two together to make a valid update stream for navbar. We then feed navbarUpdates to createView and we're done!

The navbarNode stream fires an event with the most recent view every time the user interacts with the app. The only thing we have to do is to take the latest view off the stream and to display it in the browser, which is what navbarNode.onValue does.

The App view works in the same way. We take search results coming from the server and feed them to the view:

  appViewUpdates = (searchResults.map (data) ->
    app.render (data.result)).startWith (app.render null)
  appNode = createView appViewUpdates
  appNode.onValue (appView) ->
    $('#app').replaceWith appView

Notice that we map the incoming stream to extract the result field from it. Other fields in data might, for example, contain an error description if something went wrong. We could take care of displaying errors in the same app view. Another strategy is map errors to a separate stream and handle them there. We didn't address error handling in this app but there is nothing special about it. Error streams are like any other and can be handled the same way thanks to the stream's uniform interface (e.g. a separate error view could be driven by error data mapped off the result stream).

Notice also that we don't have a single store in the application. We don't store data explicitly but simply feed it to wherever it is needed as it becomes available. The advantages of this stateless approach become more pronounced as the application gets bigger.

That was easy, wasn't it?

This concludes the first part of this article. I hope you can now see how streams naturally describe the flow of events and data inside and outside of an application without requiring us to explicitly maintain any global state. As promised, in the second part we take on the full CRUD application.

Bacon.js paired with virtual-dom brings a lot of joy and simplicity to the world of web apps, allowing us to concentrate on the application itself without spending a lot of time getting up to speed with some complex framework. It gives us the freedom to structure applications the way we want because streams provide a solid and flexible foundation. But if I failed to convince you of the merits of my no-framework approach, you could also take a look at Mercury. It combines a type of state objects with the idea of channels and provides more a framework-like style of programming.

Sergey Verhovyh

Sergey Verhovyh