The word "pure" is used 5 times at the Cycle.js.org homepage. For comparison, Angular, React, Vue and Aurelia home pages use the word "pure" the total 0 times! Why does CycleJs claim to be pure with such emphasis, while the other frameworks do not mention it at all? How can it be pure if one can listen to the user's clicks on a button and update the page for example? The example application below definitely modifies the page!
1 | function main(sources) { |
The CycleJs documentation describes its individual functions as pure,
especially the function we, the application developers are supposed to write - the main
function. Not everything is pure in the framework,
there are functions that actually update the global state (like the DOM for example).
In general every program in any environment or language MUST have non-pure functions. Otherwise a program would not read any inputs or output any information when running; it would be absolutely useless.
The difference is how the framework combines the user application code with other code. We can program in such a way that the application function is pure, while the rest of the code does the "dirty" stuff.
Here is a simple example. Imagine we have a function that reads a string name
from the standard input and then outputs "Hello $name" message back to the console.
The below code is definitely "dirty".
1 | process.stdout.write('enter your name: ') |
1 | $ node index.js |
Let us make the "logic" part of the above application pure. We need to factor out the reading of the input and message writing, leaving only their logical connection inside the "user" space.
1 | // "library" has dirty functions |
The above code separates "dirty" functions into a "library" list, while our logic that we will try to make pure remains in "main" function.
First, according to pure function definition
a pure function main
should not use "dirty" functions directly. In our case it does - it
calls write
, read
and stop
directly.
The program does NOT even work correctly. The function read
is an asynchronous function
and thus cannot return name
immediately. Thus the program runs with the following output
1 | $ node index.js |
Usually, I use promises to model asynchronous computations, but in this case another approach will allows us to kill two birds with one stone: Tasks. Here is the relevant trick and the difference between a Promise and a Task:
When we create a Promise, we execute a function. When we create a Task we "schedule" a function to be executed. Nothing runs until someone calls ".fork()" on the Task.
We can use a Task for asynchronous computation just like we could have done with a Promise. Here is how we could read the input from the terminal using a Task
1 | const Task = require('data.task') |
Looks a lot like a Promise, right? Yet Task has a
major advantage - the inner function is NOT executed
until someone runs .fork()
This is the trick we are going to use to make the "main"
function pure - instead of running any "dirty" function directly,
the "main" will just return a Task to run the "dirty" function. The "main" function does NOT
run the Task - thus the "main" never calls the "dirty" functions. Somewhere outside the "main"
function the library's bootstrapping code calls ".fork()" thus kicking off the computation.
1 | // part of the "dirty" library |
The function main
keeps its "purity". It never runs the "dirty" functions itself. In a sense it
says:
Well, I do not do these things myself, but if you call this girl's number, things can happen...
In the above example, we printed the entered result inside the .fork
callback. In reality,
it should be part of our application's logic. Since we cannot use console.log
or write
functions directly inside the "main" function, since these methods are dirty, we should
wrap them inside a Task too! As first step for clarity, just like we can pass value
from a Promise using .then(fn)
methods, we can pass value from a Task using .map(fn)
.
The value we read should be printed and then the program should stop.
1 | // "dirty" library functions |
We want to write the prompt message first (a dirty operation),
so let us really return a Task from a write
function.
Then we also need to connect write
prompt Task to read
name Task.
Luckily, connecting one Task to another is simple - just use .chain
method.
1 | const Task = require('data.task') |
Just to complete the "purification" of the main
function, we should not access the library's
functions directly via the lexical scope. Instead we will inject these functions into the main
functions. The dependency injection plays nicely with the kind of code refactoring we are
making here (see my other blog post
Dependency injection vs IO Monad example). Let us
pass the input/output functions into the main
. We can also move stop
into the library code,
since the application just stops after the chain is finished.
1 | const Task = require('data.task') |
Conclusions
Look at the function main
we have written. It only operates on its input arguments, always
returns the same result (a linked chain of Task objects), and never changes the state of the
outside. Whoever "runs" the returned linked chain will be the "dirty" function, not the main
itself.
In the same vein, a Cycle user program connects together its input streams (from the DOM
object given to the main
by the Cycle framework) and returns another set of streams.
1 | function main(sources) { |
Wait, aren't these streams like Promises? No, the streams are lazy - they do not start executing
until someone calls .subscribe()
, and thus they are like Tasks! To be complete, I must add
that the streams we created inside the main
are cold, for example the stream keeping the
current count value.
1 | const count$ = increment$.startWith(0).scan((x,y) => x+y); |
If we have created a hot stream, for example that returns current timestamp, our main
would not longer be pure - just like if we had created a Promise, because it would have kicked
off a non-pure computation rather than scheduled it for the framework code to run.
So follow the Cycle.js examples and do not
work directly inside main
with the outside state - make it work via framework's sources
and drivers.