Post-MVC part 7: Cycle.js

Intro

Last week we took a look at Reactive Programming and Observables. We saw the power that the Observables bring to the table, a very strong separation of concerns between components.

This week I want to take a look at Cycle.js which is a library which lets you create Components which take in observables as input and return observables as an output.

Cycle.js

The name Cycle.js comes from the way a Cycle.js application is structured. The philosophy behind Cycle.js is that both the computer and the user behind the computer are constantly observing and reacting to each other in a perpetual cycle.

The computer displays some data to the user. The user sees this data and moves his mouse in order to manipulate the data. The computer responds to this event by changing the data on the screen.



Lets dive into some concepts behind Cycle.js.

Everything is a stream.

Cycle.js is observables all they way from the top to the bottom. Everything is a Stream that can be observed. This includes HTTP Request,  LocalStorage reads and writes, DOM Events etc etc. But even Components in Cycle.js take streams as input and produce streams as output.

Sources & Sinks

In Cycle.js a Component takes in a collection of 'sources' and produces  a 'sink'.



The 'sources' are all the observables that the Component needs to listen to in order to do its job. For example the Component might need to listen to DOM events so it takes the DOM observable out of the 'sources'.

The 'sink' (think kitchen sink) is the end drain point of a Component. It is the collection of observables that the Component itself produces. For example a stream of HTML which represents the Component. Or a stream of values for when the Component acts as an input mechanism for a HTML
form.

A Counter Component

Lets create the Counter Component from the post about Redux in Cycle.js. Remember the Component is very simple it has two buttons one to increment the count and one to decrement the count.

Here is the code:
  
    // The Counter component
function Counter(sources) {
  // INTENT: Listen to increment and decrement clicks.
  const increment$ = sources.DOM.select('.increment')
                                .events('click').map(() => +1);
  const decrement$ = sources.DOM.select('.decrement')
                                .events('click').map(() => -1);
  
  /*
    MODEL: The count$ stream represents the current count of the counter.
    
    The state is kept by merging the increment$ and decrement$ streams,
    and then 'scanning' them on the current count.
    
    Let's say the user clicks decrement, then increment, and then
    the decrement again. The marble graph of the streams 
    to visually see what is happening:
    
    increment$: === -1 ======= -1 ===
    decrement$: ========= +1 ========
    ---------------------------------
    merged:     ====-1 == +1 = -1 ===
    ---------------------------------
    scan:       ==== -1 == 0 = -1 ===
    
    The 'scan' operator takes the current count, which starts at
    0 thanks to 'startsWith' and each time a new 'merged' value
    is available runs the function. In this case the function simply
    takes the current count and adds the modifier on top of it. The
    value that is returend from 'scan' is the next count.
    
    In ascii form:

    =============================
    | count | modifier | result |
    =============================
    |   0   |    -1    |   -1   |
    -----------------------------
    |   -1  |    +1    |   0    |
    -----------------------------
    |   0   |    -1    |   -1   |
    =============================
  */
  const count$ = Rx.Observable
    .merge(increment$, decrement$)      // Merge clicks from the buttons into one stream
    .startWith(0)                       // Start the count at zero.
    .scan((count, mod) => count + mod); // Take the current count either -1 or +1 it.
  
  /* 
    VIEW: Create a stream which represents the UI of the counter.
    Each time the count$ produces a new value the UI should be
    re-created.
    
    vtree$ stands for 'virtual DOM tree' a Virtual DOM is a structure
    representing a DOM structure but is not the actual DOM. Cycle.js
    uses a Virtual DOM to check what the difference is with the actual
    real DOM. When things differ only the differences are patched back
    to the actual DOM minimizing the DOM operations needed.
    
    A video about what a Virtual DOM is: https://www.youtube.com/watch?v=a21b-KDHG-Q
  */
  const vtree$ = count$.map(counter =>
    CycleDOM.div([
      CycleDOM.h1("Counter"),
      CycleDOM.button('.decrement', '-'),
      CycleDOM.span(String(counter)),
      CycleDOM.button('.increment', '+')
    ]));
  
  const sinks = {
    DOM: vtree$
  };
  
  return sinks;
};

/*
  Here Cycle.js will actually make sure our Counter app
  is run. Cycle.run takes the Counter function so it can
  and executes it so it can take the 'sinks' which it passes
  to the CycleDOM driver. A driver takes a 'sink' and performs
  an operation with side effects. The DOM driver will take a
  stream of DOM (vtree$) and renders it into an actual DOM element,
  in this case the #content <div>.
*/
Cycle.run(Counter, {
  DOM: CycleDOM.makeDOMDriver('#content')
});
  
A live example can be found here: http://codepen.io/anon/pen/oxaPpP?editors=0010

From reading the comments in the snippet above you can get a pretty good feel about what the Component does. It takes the stream of DOM and listens to the clicks on the buttons, then it takes those click events and uses it to modify the 'count'. When the count changes the HTML is re-rendered.

There is a pattern to the way the Components are defined. First we state all the 'Intents' the Component responds to, aka all actions from the outside world that can influence it, these are the clicks on the decrement and increment buttons.

Then we take the Intents an we transform them into a Model. The Model represents the state for a Component.

The View then listens to the Model and whenever the Model updates the View is re-rendered.

This pattern is called: Model-View-Intent. The pattern is used throughout every Component in Cycle.js.

Also notice that the HTML produces by the Component later ends up back as a 'source' for the same Component. This is why Components in Cycle.js are in a perpetual Cycle and where the name Cycle.js comes from.

Communication between Components

The Counter Component example only showed a single Counter but no inter Component communication. Let's look at a more complex example: an application which has multiple counters, and has a total count for all counters combined.

// The Counter component
function Counter(sources) {
  // Listen to increment and decrement clicks.
  const increment$ = sources.DOM.select('.increment')
                                .events('click').map(() => +1);
  const decrement$ = sources.DOM.select('.decrement')
                                .events('click').map(() => -1);
  
  const count$ = Rx.Observable.merge(increment$, decrement$)
    .startWith(0)
    // Track 'count' even when re-created when 'Create Counter' is clicked.
    .shareReplay()
    // Take the current count and perform either -1 or + 1 on it. 
    .scan((count, modifier) => count + modifier); 
  
  const vtree$ = count$.map(counter =>
    CycleDOM.div([
      CycleDOM.button('.decrement', '-'),
      CycleDOM.span(String(counter)),
      CycleDOM.button('.increment', '+')
    ]));
  
  // Expose the count$ so external Components can observe it.
  const sinks = {
    DOM: vtree$,
    count$: count$ 
  };
  
  return sinks;
}

function CounterList(sources) {
  // INTENT
  
  // Whenever 'Create Counter' button is clicked add a new IsolatedCounter.
  const addCounter$ = sources.DOM.select('.create-counter')
                                 .events('click')
                                 .map(() => IsolatedCounter(sources));
  
  // Whenever the user clicks on the 'remove' button remove the counter.
  const removeCounter$ = sources.DOM.select('.remove')
                                    .events('click')
                                    .map(() => event.target.index);
  
  // MODEL
  
  /*
    Creates the counters$ observable by merging two other streams:
    the addCounter$ and removeCounter$. Whenever one or two fires
    an event it will manipulate the list of counters.

    The list initially starts of as an empty array via the 'startWith'
    operator. 

    When either addCounter$ or removeCounter$ fires they respective 
    reducer functions are put on the stream. This causes 'scan' to
    trigger applying either the addCounter or removeCounter function 
    on the current list. The result will be the next value of the 
    counter$.
  */
  const counters$ = Rx.Observable.merge(
    addCounter$.map(counter => addCounter(counter)),
    // Add a new removeCounter reducer function on the stream. 
    removeCounter$.map(index => removeCounter(index)) 
  )
  .startWith([])
  .scan((counters, operation) => operation(counters))  
  .share(); // share so the totalCount$ and vtree$ each get their own fork.
  
  /*
    Calculates the totalCount and makes it available as totalCount$.

    The counters$ produces an array of Counter components. The counter
    objects have a count$ property we want each of those counts and
    add them together.

    It does this by combining each Counter component's count$ into
    another stream which will produce an array via 'combineLatest'.
    The array combineLatest produces is then summed up via a 'reduce'.

    The reason we use 'flatMapLatest' on the counters$ is that
    'combineLatest' also produces an observable. We do not want to
    get that 'observable' we want whatever it is producing!

    The 'flatMap' takes the observable that 'combineLatest' produces, 
    observes it and whenever combineLatest produces something, 'flatMap' 
    simply produces it to. See this video which explains it on egghead: 
    https://egghead.io/lessons/rxjs-rxjs-map-vs-flatmap
   */
  const totalCount$ = counters$.flatMapLatest(counters => {
    return Rx.Observable.combineLatest(counters.map(counter => counter.count$))
                        .map(ar => ar.reduce((total, count) => total + count, 0));
  }).startWith(0);
  
  /*
    VIEW
  
    Combine the counters$ and the totalCount$ whenever one of them
    produces a value, take the latest value of both of them produced
    and render a Virtual DOM tree with them.
  */
  const vtree$ = counters$.combineLatest(totalCount$, 
    (counters, totalCount) =>
      CycleDOM.div([
        CycleDOM.h1('Counters'),
        CycleDOM.div(counters.map((counter, index) => 
          CycleDOM.div([
            counter.DOM,
            CycleDOM.button('.remove', { index: index }, 'remove')
          ])
        )),
        CycleDOM.button('.create-counter', 'Create Counter'),
        CycleDOM.h2('Total: ' + totalCount)
      ])
  );
  
  const sinks = {
    DOM: vtree$
  };
  
  return sinks;
}

// Takes a counter and adds it to the counter list.
function addCounter(counter) {  
  // Inner reducer function, is given the list of counters.
  return function(counters) {
     return counters.concat(counter); // Add the counter.
  }
}

// Takes an index to remove a counter from the counter list.
function removeCounter(index) {
  // Inner reducer function, is given the list of counters.
  return function(counters) {
    return counters.filter((_, i) => i !== index);
  }
}

/* 
  Creates an Isolate version of the Counter component via CycleIsolate.
  This way two Counter components do not get in each others way.
  
  For fun you can alter the body of the function to:
  'return Counter({ DOM: sources.DOM });'.
  To see the effects of not having isolate Components.

  Cycle.js will behind the scenes alter the Virtual DOM of each
  Counter to make each counter identifiable. You can inspect via
  the browser to see what it does.
  
  For fun you should remove CycleIsolate and return: 
  Counter({ DOM: sources.DOM })l
*/
function IsolatedCounter(sources) {
  return CycleIsolate(Counter)({ DOM: sources.DOM });
}

Cycle.run(CounterList, {
  DOM: CycleDOM.makeDOMDriver('#content')
});

A live example can be found here: http://codepen.io/anon/pen/EKddNO?editors=0010

In the example above you can see that our friend the Counter Component exposes his internal 'count' to the outside world. CounterList will then take all counts an tally them up into a total. Two Components communicate with each other via streams, one Components sink will become part of the other Components source.

It is also interesting to note the a Cycle application and a Cycle Component share the same structure. A Cycle application, just like a Cycle Component also takes sinks and sources. You could say that a Cycle Component is simply a Cycle Application, A Cycle Application is then simply a Cycle Component. The name Cycle.js was really well chosen!

I have to give Sean Bowman credit for coming up with how to do multiple counters in this gist.

Resources

The Cycle.js website is a real treat because it elegantly and might I say beautifully explains its philosophy.

The creator of Cycle.js André Staltz also greated a great video course explaining the basics of Cycle.js: https://egghead.io/series/cycle-js-fundamentals.

Conclusions

This week we saw and observable based Component architecture in practice. In the weeks before that we saw a unidirectional architecture with Flux and Redux.

Next week I want share with you some of my conclusions.

6 comments:

  1. Getting a Bluetooth adapter for your PC is the easiest way to add Bluetooth functionality to a desktop or laptop. You don't need to worry about opening your computer, installing a Bluetooth card, or anything like that. Bluetooth dongles use USB, so they plug into the outside of your computer via an open USB port.
    the foundry crack


    ReplyDelete
  2. Everything is clear and there is a clear explanation for the problem.Your website is very useful. Thanks for sharing.EximiousSoft Logo Designer Pro Crack

    ReplyDelete
  3. PassFab iOS Password Manager Crack is a tool designed to locate and search for lost or forgotten secret keys on iOS devices such as the iPhone and iPad. All of the passwords that have been placed on IDs, such as Wi-Fi passwords, mail accounts, application and site login secret keys, as well as data about Apple ID, can be recovered.

    ReplyDelete
  4. This comment has been removed by the author.

    ReplyDelete
  5. สล็อต เว็บ ตรง จากค่ายเกมสล็อตออนไลน์ชื่อดังอันดับ 1 ที่นักพนันทั่วโลกให้การยอมรับว่าเป็นค่ายเกมสล็อตออนไลน์ที่ดีที่สุดตลอดกาลกับค่าย PG SLOT ที่มาแรงที่สุดในปี 2022

    ReplyDelete