Reactive Programming in Practice

(Using Bacon JS)

By Enrique Amodeo / @eamodeorubio

Enrique Amodeo

(who is this guy?)

[Enrique Amodeo, circa 2015]
  • Programming since 1984
  • Currently Software Engineer at SoundCloud
  • Has loved JS since 2005
  • Test infected
  • Enthusiast of the Agile/Lean way
  • Follow me at @eamodeorubio

This talk

  1. Reactive?
  2. Reactive DOM
  3. AJAX the Reactive Way
  4. Nested Streams
  5. Combining Streams
  6. Finishing (touches)
[Learning Behavior-driven Development with JavaScript]

Warning!!

Not Recommended for Sensitive Audiences

  • There will be code !
  • Shitty custom marble diagrams !

Reactive?

Original by Squeezyboy: http://www.flickr.com/photos/squeezyboy/130035888/

Reactive Programming?

(be water my friend)

  • Flow of events as first class citizens
  • Event transformation: map, flatMap ...
  • Temporal transformation: delay, throttle, debounce ...
  • Composition: merge, zip, combine ...

Reactive Observable

(remember observer pattern?)

  • Models a flow of events
  • An EventEmitter on steroids
  • START, DATA, ERROR, END events
  • Transformation methods: map, flatMap, delay, ...
  • Two kinds: Streams & Properties

Why?

  • Batch performance is not important any more
  • Responsiveness is what the user wants
  • Fast feedback to the user
  • Process user input ASAP
  • Show results ASAP
  • Do not wait for "completion"

Let's have a look

(I know, my web design skills suck !)

Bacon

(it's healthy)

Reactive DOM

Original by Carl Milner: http://www.flickr.com/photos/62766743@N07/9287407448/

Getting the Stream

   var keyUpStream = $('input.search-text')
                                .asEventStream('keyup');

Getting the Stream

    var queryTextStream = $('input.search-text')
                                .asEventStream('keyup')
                                .map(function extractValue(ev) {
                                   return ev.target.value;
                                });

Getting the Stream

(with style)

    
      var queryTextStream = $('input.search-text')
                                .asEventStream('keyup')
                                .map('.target.value');
  

Getting the Stream

(no duplicates)

    
      var queryTextStream = $('input.search-text')
                                .asEventStream('keyup')
                                .map('.target.value')
                                .skipDuplicates();
  

Streams VS. Properties

Getting a reactive property


    var $inputSearch = $('input.search-text');
    var queryTextStream = $inputSearch
                              .asEventStream('keyup')
                              .map('.target.value')
                              .skipDuplicates()
                              .toProperty($inputSearch.get(0).value);
  

Getting a reactive property

(with style)


    function valueFrom(selector, event) {
      event = event || "change";
      var $el = $(selector);
      var prop = $el
              .asEventStream(event)
              .map('.target.value')
              .skipDuplicates()
              .toProperty($el.get(0).value);
      return prop;
    }

    var queryTextStream = valueFrom('input.search-text', 'keyup');
  

AJAX the Reactive Way

Original by Randy Merrill: http://www.flickr.com/photos/zoramite/3994053516/

Thou shall stream!

Server Sent Events

(web sockets are so posh)

    var dataSource = new EventSource(url);
    // Listen for any message
    dataSource.addEventListener('message', function (event) {
      console.log(event.data);
    });
    dataSource.addEventListener('open', function(event) {
      // Connection is open !
    });
    dataSource.addEventListener('error', function(event) {
      if (e.readyState == EventSource.CLOSED) {
        // Connection is closed!
      }
    });
    // Listen for messages of custom type "some-msg-type"
    dataSource.addEventListener('some-msg-type', function (event) {
      console.log(event.data);
    });

Reactice Server Sent Events

(using Bacon.fromBinder)

  function eventStreamForQuery(queryString) {
    var dataSource = new EventSource("books?" + queryString);
  
    function close() {
      if (dataSource) {
        dataSource.close();
        dataSource = null;
      }
    }

    return Bacon.fromBinder(function (output) {
      // Add code here to emit Bacon events
      
      return close; // Return "cleaning" function
    });
  }

Reactice Server Sent Events

(Bacon.End and Bacon.Next)


  function eventStreamForQuery(queryString) {
    // ... Omitted for brevity ...
    return Bacon.fromBinder(function(output) {
      
      dataSource.addEventListener('end', function () {
        output(new Bacon.End());
        close();
      });

      dataSource.addEventListener('data', function (event) {
        output(new Bacon.Next(JSON.parse(event.data)));
      });

      return close;
    });
  }
  

Reactice Server Sent Events

(Empty stream)


  function eventStreamForQuery(queryString) {
    if (!queryString)
      return Bacon.never(); // Only "end" no data

    var dataSource = new EventSource("books?" + queryString);
  
    function close() {
       // ... Omitted for brevity ...
    }

    return Bacon.fromBinder(function(output) {      
      // ... Omitted for brevity ...
    });
  }
  

Nested Streams ?

Original by Gláucia Góes: http://www.flickr.com/photos/glauciagoes/4917832866/

A stream resulting from each event?

(a bit frightening)


    var streamsOfStreams = queryTextStream
        .map(queryToQueryString)
        .map(eventStreamForQuery)
        .doAction(function(resultStream) {
            // Mmm, inception or what??
            return resultStream
              .map(toSomethingMeaningful)
              .doAction(doSomethingElse)
              .doAction(renderResult);
        });
  

Unwrapping nested streams


    var streamsOfItems = queryTextStream
        .map(queryToQueryString)
        .flatMap(eventStreamForQuery)
        .map(toSomethingMeaningful)
        .doAction(doSomethingElse)
        .doAction(renderResult);
  

We have a problem!

Discarding old streams


    var streamsOfItems = queryTextStream
        .map(queryToQueryString)
        .flatMapLatest(eventStreamForQuery)
        .map(toSomethingMeaningful)
        .doAction(doSomethingElse)
        .doAction(renderResult);
  

We have a problem!

We need to clean the UI!

Inserting begin and end marks


    function toStreamWithMarks(stream) {
      return Bacon.once("NEW-QUERY")
                  .concat(stream
                            .mapEnd("END-QUERY")
                  );
    }
    var streamsOfItems = queryTextStream
              .map(queryToQueryString)
              .map(eventStreamForQuery)
              .flatMapLatest(toStreamWithMarks)
              .doAction(doSomethingElse)
              .doAction(renderResult);
  

OO + RX = WIN

(Our old friend the polymorphism)


    function toStreamWithMarks(stream) {
      return Bacon.once(StartQuery)
                  .concat(stream
                            .map(ResultItem)
                            .mapEnd(EndQuery)
                  );
    }
    var streamsOfItems = queryTextStream
              .map(queryToQueryString)
              .map(eventStreamForQuery)
              .flatMapLatest(toStreamWithMarks)
              .doAction(".render");
  

OO + RX = WIN


    function StartQuery() {
      var $inProgressIndicator = $('.waiting-msg'),
          $itemsFoundCounter = $('.item-count'),
          $resultContainer = $('.result-container');

      return {
        render: function () {
          $inProgressIndicator.show();
          $itemsFoundCounter.text('0');
          $resultContainer.html('');
        }
      };
    }
  

OO + RX = WIN

    function ResultItem(book) {
      var $template = $('.templates .book'),
          $resultContainer = $('.result-container');
      return {
        render: function () {
          var $bookView = $template.clone(false);
          var $titleLink = $bookView.find('.title a');
          $titleLink.attr('href', book.link);
          $titleLink.text(book.title);
          $bookView.find('.description img').attr('src', book.cover);
          $bookView.find('.price span').text(book.price);
          $bookView.find('.authors ul')
                   .html(book.authors.map(htmlForAuthor).join(''));
          $resultContainer.append($bookView);
        }
      };
    }
  

OO + RX = WIN


    function EndQuery() {
      var $inProgressIndicator = $('.waiting-msg');
      return {
        render: function () {
          $inProgressIndicator.hide();
        }
      };
    }
  

Combining streams

Original by VancityAllie.com: http://www.flickr.com/photos/30691679@N07/4541010137/

I want min price & max price


    // Reactive Property for description criteria
    var queryByDescription = valueFrom('input.search-text', 'keyup');

    // Reactive Property for minimum price criteria
    var queryByMinPrice = valueFrom('input.min-price')
              .map(Number)
              .doAction(renderPriceInto('.min-price-output'));

    // Reactive Property for maximum price criteria
    var queryByMaxPrice = valueFrom('input.max-price')
              .map(Number)
              .doAction(renderPriceInto('.max-price-output'));
  

I want min price & max price


    // Whole query criteria
    var queryStream = shakedNotStirred(
      queryByDescription,
      queryByMinPrice,
      queryByMaxPrice
    );

    var streamsOfItems = queryStream
              .map(queryToQueryString)
              .map(eventStreamForQuery)
              .flatMapLatest(toStreamWithMarks)
              .doAction(".render");
  

Do I merge?


    // Note: this does not work with properties
    var queryStream = queryByDescription
              .merge(queryByMaxPrice)
              .merge(queryByMinPrice);
  
Original by BaconJS: https://raw.github.com/wiki/baconjs/bacon.js/baconjs-merge.png

Do I zip?

    var queryStream = Bacon.zipWith(
        function(desc, minPrice, maxPrice) {
          return {
            q: desc,
            minPrice: minPrice,
            maxPrice: maxPrice
          };
        },
        queryByDescription,
        queryByMinPrice,
        queryByMaxPrice
    );
  • Combines events into single object/event
  • But waits for all streams to emit!

Combine

    var queryStream = Bacon.combineWith(
        function(desc, minPrice, maxPrice) {
          return {
            q: desc,
            minPrice: minPrice,
            maxPrice: maxPrice
          };
        },
        queryByDescription, queryByMinPrice, queryByMaxPrice);
Original by BaconJS: https://raw.github.com/wiki/baconjs/bacon.js/baconjs-combine.png

Combine Template!

Pow!


    var queryStream = Bacon.combineTemplate({
      q: queryByDescription,
      minPrice: queryByMinPrice,
      maxPrice: queryByMaxPrice
    });
  

Finishing (touches)

Original by midwestnerd: http://www.flickr.com/photos/20553990@N06/6278005315/

Not so fast


    var streamsOfItems = queryStream
              .debounce(200)
              .map(queryToQueryString)
              .map(eventStreamForQuery)
              .flatMapLatest(toStreamWithMarks)
              .doAction(".render");
  
  • throttle Minimum time between events
  • debounce Fires event if minimum pause

Min Price <= Max Price


    var queryStream = Bacon.combineTemplate({
      q: queryByDescription,
      minPrice: queryByMinPrice,
      maxPrice: queryByMaxPrice
    }).doAction(function (query) {
      if (query.minPrice > query.maxPrice)
        $('input.max-price').val(query.minPrice).trigger('change');
    });
  

How many results?

    function StartQuery() {
      return {
        // ... Omitted code
        countItems: function () { return -1; }
      };
    }
    function ResultItem(book) {
      return {
        // ... Omitted code
        countItems: function () { return 1; }
      };
    }
    function EndQuery() {
      return {
        // ... Omitted code
        countItems: function () { return 0; }
      };
    }
  

How many results?

 var foundItemCount = streamsOfItems
          .map(".countItems")
          .scan(0, function(currentTotalItems, itemCount) {
            return itemCount >= 0 ? currentTotalItems + itemCount : 0;
          });
  
Original by BaconJS: https://raw.github.com/wiki/baconjs/bacon.js/baconjs-scan.png

How many results?

    
    foundItemCount
            .map(String)
            .assign($('.total-count'), "text");
  

The End

Some questions?

Original by Ethan Lofton: http://bit.ly/1arHGj