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:
- 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. - 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.
- 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
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".
// 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;
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...
- 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.
- 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.
- 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:
- I no longer have Cycle.js handling cyclical events for me, so now I use Subjects to have event handlers feed events to them.
- I have the output stream output a React Element tree.
- I subscribe to this output stream with a React.render for my output and target container.
- 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
- Cycle.js -- http://cycle.js.org/getting-started.html
- RxJS -- https://github.com/Reactive-Extensions/RxJS/
- My Repo for this -- https://github.com/justinwoo/cycle-scroll-table