The way FRP is explained often leads to confusion. Most people know what the P is, and the F seems fairly understandable, but the R causes confusion. Examples usually talk about the difference between expressions and statements. Rather than c = a + b setting a value right now, it is an expression which defines that c is always a plus b. It defines a relationship.

What is an event stream?

This event listener and handler in jQuery:

  $('#button').on('click', function (event) {
  console.log(event.target);
});

can be baconified by doing:

  clickStream = $('#button').asEventStream('click')
clickStream.onValue(function (event) {
  console.log(event.target);
})

Not a big difference so far, but Bacon provides a functional interface to manipulate and handle events (moving to ES6 from now on):

  clickStream
  .map((event) => event.target)
  .onValue((element) => console.log(element));

clickStream
  .skip(1)
  .take(4) // will only take the 2-5 click events
  .onValue((event) => console.log(event.target));

clickStream
  .filter((event) => event.type == 'click')
  .onValue((event) => console.log(event.target))

Now try rewriting that using callbacks...

So, what's exactly FRP?

FRP is this: working with streams of values that change over time.

Perhaps an example would help. Imagine moving a mouse over your browser. It produces a stream of x and y values. Rather than using a callback for every mouse move, we can work with the mouse events as a single object over time: an event stream. Suppose we want to know when the mouse moves past a line on the screen at 100px. In regular code we could do this:

  $('body').on('mousemove', function (e) {
  if (e.pageX > 100) {
    console.log('we are over 100');
  }
};

With FRP we would create a stream based on mouse move, then filter it to only have X values over 100, like this:

  $('body').toEventStream('mousemove')
  .filter((v) => v > 100)
  .onValue((v) => console.log(`we are over 100: ${v}`));

We have separated the action, printing a message, from the source of the stream and any filter operations. We can also add more operations to the stream if we want, and abstract the filters out further.

Bacon.js Usage Examples

Merge

Say we have two buttons, one for enabling something and one for bringing it back to a disabled state.

  let enable = $('#enable').asEventStream('click').map(true);
let disable = $('#disable').asEventStream('click').map(false);
enable.merge(disable).onValue((state) => console.log(state));

Properties

A property is basically an event stream with a notion of state. The property will remember the state of the stream (which is the event object or mapped value). It also provides two helper methods: scan and assign (more on this later, stay tuned).

  buttonState = enable.merge(disable).toProperty(false); // initial state = false
buttonState.onValue((state) => $('#button').toggleClass('enable', state));

Buses (message queues)

  let messageQueue = new Bacon.Bus();

// plug event streams to queue
messageQueue.plug(enable.map({ type: 'enable' }));
messageQueue.plug(disable.map({ type: 'disable' }));

messageQueue.onValue((event) => console.log(event.type)); // listen and echo out event state

messageQueue.push({ type: 'disable' }); // push event manually, and log event

AJAX

We can listen to the promise object as a event stream.

  let response = enable.map({ url: '/enable', method: 'post' }).ajax();
response.onValue((data) => console.log(data));

FlatMap

  $.ajax({ url: '/items' }).done(function (items) {
  if (items.error) {
    return handleError(items.error);
  }

  $.each(items, function (item) {
    $.ajax({ url: '/item/' + item.id }).done(function (item) {
      if (item.error) {
        return handleError(item.error);
      }

      renderItem(item, function (itemEl) {
        itemEl.children('.delete').click(function () {
          $.ajax({ url: '/item/' + item.id, type: 'DELETE' }).done(function (response) {
            if(response.error) {
              return handleError(response.error);
            }

            itemEl.remove();
          });
        });
      });
    });
  });
});

Bacony version:

  var isError = function (serverResponse) {
  return typeof serverResponse.error !== 'undefined';
}

var isNotError = function (serverResponse) {
  return !isError(serverResponse);
}

var $allItems = Bacon.fromPromise($.ajax({ url: '/items' }));
var $errors = $allItems.filter(isError);
var $items = $allItems
  .filter(isNotError)
  .flatMap(function (item) {
    return Bacon.fromPromise($.ajax({ url: '/item' + item.id }));
  });

$errors.merge($items.filter(isError));

var $renderedItems = $items
  .filter(isNotError)
  .flatMap(function(item) {
    return Bacon.fromCallback(renderItem, item);  
  });

var $renderedItemsDeleteClicks = $renderedItems
  .flatMap(function (renderedItem) {
    return Bacon.fromEventTarget(renderedItem.children('.delete'), 'click', function () {
      return renderedItem;
    });
  });

var $deleteItemRequest = $renderedItemsDeleteClicks
  .flatMap(function (renderedItem) {
    return Bacon.fromPromise($.ajax({ url: '/item' + renderedItem.data('id'), type: 'DELETE' }));
  });

$errors.merge($deleteItemRequest.filter(isError));
$errors.onValue(handleError);
$deleteItemRequest.filter(isNotError).onValue('.remove');

When and Combine

  isError = function(serverResponse) { return typeof serverResponse.error !== 'undefined' }
isNotError = function(serverResponse) { return !isError(serverResponse); }

$items = Bacon.fromPromise($.ajax({ url: '/items' }));
$renderedItems = $items.filter(isNotError).flatMap(function(item) {
  return Bacon.fromCallback(renderItem, item);  
});
$quantity = $renderedItems.flatMap(function(element) {
  return Bacon.fromEventTarget($(element).children('.quantity'), 'change').map(function(event) {
    return [element, $(element).val()];
  });
});
$price = $quantity.map(function(data) {
  return $(data[0]).data('price') * data[1];
});

$refreshClick = Bacon.fromEventTarget($('#refresh_cart'), 'change');
Bacon.when(
  [$refreshClick, $quantity, $price], function(event, data, price) {
    $(data[0]).children('.internal-price').text(price);
    $(data[0]).children('.price').text(price);
  },
  [$quantity, $price], function(data, price) {
    $(data[0]).children('.internal-price').text(price);
  }
);

Buses

  var $deleteItem = new Bacon.Bus();
$deleteItem.plug(Bacon.fromEventTarget($('.delete'), 'click'));
$deleteItem.map('.target.remove');
$deleteItem.push($('item1'));

Hungry philosophers:

  var chopsticks = [new Bacon.Bus(), new Bacon.Bus(), new Bacon.Bus()]
var hungry     = [new Bacon.Bus(), new Bacon.Bus(), new Bacon.Bus()]
var eat = function(i) {
  return function() {
    setTimeout(function() {
      chopsticks[i].push({})
      chopsticks[(i+1) % 3].push({})
    }, 1000);
    return 'philosopher ' + i + ' eating'
  }
}

var dining = Bacon.when(
  [hungry[0], chopsticks[0], chopsticks[1]],  eat(0),
  [hungry[1], chopsticks[1], chopsticks[2]],  eat(1),
  [hungry[2], chopsticks[2], chopsticks[0]],  eat(2)
).log()

// make all chopsticks initially available
chopsticks[0].push({}); chopsticks[1].push({}); chopsticks[2].push({})
// make philosophers hungry in some way, in this case we just push to their bus
for (var i = 0; i < 3; i++) {
  hungry[0].push({}); hungry[1].push({}); hungry[2].push({})
}

Usage with Node.js

  getInvoiceStream = (id) -> Bacon.fromNodeCallback Invoice, 'findOne', id: id
getInvoiceDataStream = ($invoice) -> $invoice.flatMap (invoice) ->
  Bacon.fromNodeCallback invoice, 'toDeepJSON'

# Load Invoice and its deps
get: (req, res) ->
  $invoice = getInvoiceStream req.param 'id'
  $invoiceData = getInvoiceDataStream invoice
  $invoiceData.onValue _.bind(res.json, res)

  $errors = Bacon.mergeAll $invoice.errors(), $invoiceData.errors()
  $errors.onError _.bind(res.send, res, 500)

# Generate PDF export
pdf: (req, res) ->
  $invoice = getInvoiceStream req.param 'id'
  $invoiceData = getInvoiceDataStream $invoice
  $invoiceRender = $invoiceData.map renderInvoicePDF
  $invoiceRenderData = $invoiceRender.flatMap (pdf) -> Bacon.fromCallback pdf, 'output'
  $invoiceRenderData.onValue _.bind(res.end, res)

  $errors = Bacon.mergeAll $invoice.errors(), $invoiceData.errors()
  $errors.onError _.bind(res.send, res, 500)

AJAX Search

  // Stream of search queries
var $query = $('#search').asEventStream('keyup').map(function(event) {
  return $(event.srcElement).val();
}).skipDuplicates();

// Search result strings
var $results = $query.throttle(500).flatMapLatest(function(query) {
  return Bacon.fromPromise($.ajax('/search/' + query))
}).mapError('Search error');

// Render results
$results.onValue(function(result) {
  $('#results').html(result);
});

// Search status
var searchStatus = $query.map(true).merge($results.map(false)).skipDuplicates().toProperty(false);

Debugging

  Observable.log()
Observable.toString()
Observable.deps()
Observable.internalDeps()
Bacon.spy(f)

Notices

  onValue
fromArray

Resources


337 0 3