6
6

More than 5 years have passed since last update.

Updated: How to use Cycle.js to create a scroll-table

Last updated at Posted at 2015-07-03

The first version of my article was pretty messy, and Andre helped rewrite it so that it was cleaner and pure.

Why Cycle.js?

It's pretty neat in how it lets you write your application in terms of data streams (implemented in RxJS) using Observables that return a VDOM tree, and it's in many ways "the next step" to what I described in my previous post "Using RxJS for data flow instead of Flux with React".

There are three big differences from React:

  1. No "component lifecycle" -- in React, I abuse the component lifecycle in order to do things after a "logical component" has been rendered, so that I can manipulate the DOM nodes in the document directly. But in return, because of the large cost of these component lifecycle events (and React-style mixins), if you want to render a large number of items, you have to get pretty creative with how you limit component render calls.
  2. No component state -- in React, I might store a lot of data that only the component cares about, but then makes things more tricky to debug, as app state is a product of app state + the sum of all component states.
  3. Write functions, not class/object definitions -- It's very convenient to just create React component definitions and stick them into other components' render methods and all, but it is very heavy compared to a simple function that returns VDOM trees.

I think the two features in React make React very easy to use, but are quite dangerous and really annoying to debug. There are definitely a lot of people who are switching to a "props-only" approach to React components, but you still do deal with everything else being there. Well, not that we don't already restrict ourselves elsewhere.

Well, we might as well get started with the actual code.

Code time

Initial project setup

Let's get some basic stuff set up in our project:

src
  main.js
.gitignore
index.html
package.json
webpack.config.js

I use webpack to build my javascripts, babel to do my ES6 compilation, and npm to download my packages.

Download away with npm i -D [module_name]: https://github.com/justinwoo/cycle-scroll-table/blob/25ca120416fb42a74ad8cf489f8d20a3ed84290b/package.json

Set up your webpack config: https://github.com/justinwoo/cycle-scroll-table/blob/25ca120416fb42a74ad8cf489f8d20a3ed84290b/webpack.config.js

Set up your npm start task: https://github.com/justinwoo/cycle-scroll-table/blob/25ca120416fb42a74ad8cf489f8d20a3ed84290b/package.json#L7

Write some boilerplate HTML for index.html: https://github.com/justinwoo/cycle-scroll-table/blob/25ca120416fb42a74ad8cf489f8d20a3ed84290b/index.html

Updated project setup

This used to use a very lazy setup, but Andre helped turn it into a proper Model-View-Intent application.

▾ src/
  ▾ models/
      main-model.js -- our main app model as a function of our actions stream
      make-visible-indices.js -- function for aggregating streams to make the visible indices stream
  ▾ views/
      main-view.js -- the main view of our app as a function of our state stream
      tbody.js -- a simple function for calculating my tbody
      thead.js -- another simple function for calculating my thead
    intent.js -- a function for the intent, as a function of our DOM object that we can use to create an object of streams
    main.js -- the main entry point of our app

Getting started/app bootstrap

main.js:

import Cycle from '@cycle/core'; // bring in CycleJS core stuff
import CycleWeb from '@cycle/web'; // bring in CycleJS Web driver for DOM interaction and whatnot

import intent from './intent'; // bring in the intent in our app
import model from './models/main-model'; // same for model
import view from './views/main-view'; // same for view

function main({DOM}) {
  let actions = intent(DOM); // supply the DOM object to intent to get the actions
  let state$ = model(actions); // then feed the actions object of streams to model to get our current state
  let vtree$ = view(state$); // then feed our state into the view to get the snapshot view of that state
  return { DOM: vtree$ }; // return this vtree stream the DOM driver to consume
}

let drivers = {
  DOM: CycleWeb.makeDOMDriver('#app')
};

let drivers = {
  DOM: CycleWeb.makeDOMDriver('#app') // take over the main app container to render my VDOM to
};

Cycle.run(main, drivers); // run Cycle.js, like React.render in a way, using the main function defined above

Intent

Our application only has a single action to be handled. intent.js

function intent(DOM) {
  let actions = {
    userScrolled$: DOM.get('#scroll-table-container', 'scroll')
      .map(e => e.srcElement.scrollTop)
  };
  return actions;
}

export default intent;

The DOM object comes from main, where we can use the get method of this object to get our rendered node so that we can handle scroll events.

Model

This is where we take the actions stream and create a state stream based on that. model.js

import {Rx} from '@cycle/core';

import makeVisibleIndices$ from './make-visible-indices';

function model(actions) {
  let tableHeight$ = Rx.Observable.just(500);
  let rowHeight$ = Rx.Observable.just(30);
  let columns$ = Rx.Observable.just(['ID', 'ID * 10', 'Random Number']);
  let rowCount$ = Rx.Observable.just(10000);
  let scrollTop$ = actions.userScrolled$.startWith(0);
  let visibleIndices$ = makeVisibleIndices$(
    tableHeight$, rowHeight$, rowCount$, scrollTop$
  );
  let state$ = Rx.Observable.combineLatest(
    tableHeight$, rowHeight$, columns$, rowCount$, visibleIndices$,
    (tableHeight, rowHeight, columns, rowCount, visibleIndices) =>
      ({tableHeight, rowHeight, columns, rowCount, visibleIndices})
  );
  return state$;
}

export default model;

Making the Visible Indices Stream

Of course, I'm not very original. The original algorithm was written in Eric Miller's article "Create an Infinite Scroll List with Bacon.js".

make-visible-indices.js

// just bring in Rx from Cycle Core
import {Rx} from '@cycle/core';

// get the visible indices stream as a function of the streams that make up the data for this
function makeVisibleIndices$(tableHeight$, rowHeight$, rowCount$, scrollTop$) {
  // calculate what the first visible row will be based off the height of the rows and how far we scrolled down
  // limit the stream output to distinct values per click
  let firstVisibleRow$ = Rx.Observable.combineLatest(scrollTop$, rowHeight$,
    (scrollTop, rowHeight) => Math.floor(scrollTop / rowHeight)
  ).distinctUntilChanged();

  // calculate how many rows will even be visible, i.e. how many rows fit into the height of the table
  let visibleRows$ = Rx.Observable.combineLatest(tableHeight$, rowHeight$,
    (tableHeight, rowHeight) => Math.ceil(tableHeight / rowHeight)
  );

  // calculate the visible indices based on the above two streams and how many rows we have in our application
  let visibleIndices$ = Rx.Observable.combineLatest(
    rowCount$, visibleRows$, firstVisibleRow$,
    (rowCount, visibleRows, firstVisibleRow) => {
      let visibleIndices = [];
      let lastRow = firstVisibleRow + visibleRows + 1;

      if (lastRow > rowCount) {
        firstVisibleRow -= lastRow - rowCount;
      }

      for (let i = 0; i <= visibleRows; i++) {
        visibleIndices.push(i + firstVisibleRow);
      }
      return visibleIndices;
    }
  );

  return visibleIndices$;
}

export default makeVisibleIndices$;

My view functions

And so, using the state stream we get from our model, we can render our view. views/main-view.js

import Cycle from '@cycle/core';
import {h} from '@cycle/web';

import renderTHead from './thead';
import renderTBody from './tbody';

// returns a vtree stream based on this state stream
function view(state$) {
  return state$.map(({tableHeight, rowHeight, columns, rowCount, visibleIndices}) =>
    h(
      'div#app-container',
      [
        h(
          'table#static-header-table',
          {
            style: {
              overflowX: 'hidden',
              borderBottom: '1px solid black'
            }
          },
          renderTHead(columns) // get the vtree of THead as a product of columns
        ),
        h(
          'div#scroll-table-container',
          {
            style: {
              position: 'relative',
              overflowX: 'hidden',
              borderBottom: '1px solid black',
              height: tableHeight + 'px',
            }
          },
          h(
            'table#scroll-table',
            {
              style: {
                height: rowCount * rowHeight + 'px'
              }
            },
            renderTBody(rowHeight, visibleIndices) // same for TBody, using row heights and visible indices
          )
        )
      ]
    )
  );
}

export default view;

views/thead.js

views/tbody.js

Wrapping up

Check out the repo and demo here: https://github.com/justinwoo/cycle-scroll-table

As you've probably noticed, I pasted most of all 167 lines of code for this application in this article. Does that seem like a lot? The React version with components and RxJS is around the same number of lines, but uses a lot of complicated local component state to figure stuff out with the rendered DOM nodes.

Overall, I really enjoyed trying out Cycle.js. It seems like a really hardcore framework at first, but it's actually quite practical and isn't too hard to use, especially if you already use React or RxJS.

Though, if I had to give some complaints based on one day of use, it'd be...

  1. Not enough fanboys -- having a huge userbase is important for being able to use other people's ideas and code (just look at React, Angular, jQuery). With a big focus in purity and reactive programming, I think this is a little punishing for people who want to write easy code, me included.
  2. No JSX -- it's WIP, so this complaint will be addressed and go away soon, but this is important to me. Not because I need JSX, but I like using it to easily guess what I'm going to get in my output, and it's familiar. Not to mention, I can introduce React to almost any project and everyone can contribute code very easily, which will inevitably be harder with the virtual-hyperscript functions.
  3. Not a lot of people care about reactive programming (in JS) -- I think code is simpler and easier to write using some basic Observables and Subjects with operators like Combine Latest, but a lot of people really want async single-value resolution and event emitters. I think that kind of coding is really hard to debug and follow through, but alas, that's what a lot of people code with. Maybe more articles and works like Dan Abramov's Redux will make people start to care more about different ways to do things, but who knows. Some people also love channels, but to me, that's like eating wet rice with chopsticks -- you can do it, and you can glue together chopsticks and carve out a scoop, but shit, I just want a spoon from the start.

If you got this far, thanks for reading! (or for just scrolling down)

Let me know on Twitter if this sucks or is kind of okay.

Thanks again to Andre, who fixed this code to make it pure and much cleaner.

Update #1

Andre helped fix all of my code, so I'll be rewriting a large chunk/all of this article accordingly.

Update #2

Rewrote chunks of this article accordingly with the MVI bits.

Update #3: React-based views

Because Cycle.js is mostly just architecture that facilitates cyclical streams, it's easy to just substitute in React-based views.

All of the changes are in this commit.

Main differences:

  1. I no longer have Cycle.js handling cyclical events for me, so now I use Subjects to have event handlers feed events to them.
  2. I have the output stream output a React Element tree.
  3. I subscribe to this output stream with a React.render for my output and target container.
  4. I have to provide key data for React's reconciliation.

There might be some real use to this other than just for having familiar React code, such as reusing some of the animation tools that are being developed for React. Otherwise, this is just a demonstration of how Cycle.js applications don't require massive buy-in to anything other than RxJS Observables.

Links

6
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
6