Is framework pure or not?

How is Cycle.js a pure framework if I can listen to the UI events and output new DOM?

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
2
3
4
5
6
7
8
9
10
11
12
function main(sources) {
const increment$ = sources.DOM
.select('.increment').events('click').map(ev => +1);
const count$ = increment$.startWith(0).scan((x,y) => x+y);
const vtree$ = count$.map(count =>
div([
button('.increment', 'Increment'),
p('Counter: ' + count)
])
);
return { DOM: vtree$ };
}

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
2
3
4
5
6
7
8
9
process.stdout.write('enter your name: ')
process.stdin.setEncoding('utf8')
process.stdin.on('readable', () => {
const name = process.stdin.read()
if (name !== null) {
process.stdout.write(`hello ${name}`)
process.exit(0)
}
})
1
2
3
$ node index.js 
enter your name: world
hello world

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// "library" has dirty functions
function write(text) {
process.stdout.write(text)
}
function read() {
process.stdin.setEncoding('utf8')
process.stdin.on('readable', () => {
const name = process.stdin.read()
if (name !== null) {
return name
}
})
}
function stop() {
process.exit(0)
}
// "user" program should be pure
function main() {
write('enter your name:')
const name = read()
write(`hello ${name}\n`)
stop()
}
main()

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
2
$ node index.js 
enter your name:hello undefined

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const Task = require('data.task')
function read() {
return new Task(function (reject, resolve) {
process.stdin.setEncoding('utf8')
process.stdin.on('readable', () => {
const name = process.stdin.read()
if (name !== null) {
resolve(name)
}
})
})
}
function stop() {
process.exit(0)
}
function main() {
read() // nothing has happened yet
.fork(console.error, (name) => {
console.log('you entered', name)
stop()
})
}
main()

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
2
3
4
5
6
7
8
9
10
11
12
13
14
// part of the "dirty" library
function run(appFn) {
appFn()
.fork(console.error, (name) => {
console.log('you entered', name)
stop()
})
}
// "user" application function
function main() {
return read() // nothing has happened yet
}
// framework's bootstrap
run(main)

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// "dirty" library functions
const Task = require('data.task')
function write(text) {
process.stdout.write(text)
}
function read() {
return new Task(function (reject, resolve) {
process.stdin.setEncoding('utf8')
process.stdin.on('readable', () => {
const name = process.stdin.read()
if (name !== null) {
resolve(name)
}
})
})
}
function stop() {
process.exit(0)
}
function run(appFn) {
appFn()
.fork(console.error, console.log)
}
// "user" application function
function main() {
return read()
.map(write)
.map(stop)
}
// framework's bootstrap
run(main)

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const Task = require('data.task')
function write(text) {
process.stdout.write(text)
return Task.of()
}
function read(value) {
return new Task(function (reject, resolve) {
process.stdin.setEncoding('utf8')
process.stdin.on('readable', () => {
const name = process.stdin.read()
if (name !== null) {
resolve(name)
}
})
})
}
function stop() {
process.exit(0)
}
function run(appFn) {
const noop = () => {}
appFn()
// fork really needs TWO arguments to work
.fork(console.error, noop)
}
// "user" application function
function main() {
return write('enter your name: ')
.chain(read)
.map((name) => `hello ${name}`)
.chain(write)
.map(stop)
}
// framework's bootstrap
run(main)

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const Task = require('data.task')
function write(text) {
process.stdout.write(text)
return Task.of()
}
function read(value) {
return new Task(function (reject, resolve) {
process.stdin.setEncoding('utf8')
process.stdin.on('readable', () => {
const name = process.stdin.read()
if (name !== null) {
resolve(name)
}
})
})
}
function run(appFn) {
function stop() {
process.exit(0)
}
const io = {
read: read,
write: write
}
appFn(io, process)
.fork(console.error, stop)
}
// "user" application function
function main(io) {
return io.write('enter your name: ')
.chain(io.read)
.map((name) => `hello ${name}`)
.chain(io.write)
}
// framework's bootstrap
run(main)

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
2
3
4
5
6
7
function main(sources) {
// main only used its input arguments!
const increment$ = sources.DOM.select('.increment') ...
...
// main has never run "dirty" commands itself
return { DOM: vtree$ };
}

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.