TSERS
Transducer-Signal-Executor framework for Reactive Streams (RxJS only at the moment... ๐).
"tsers!"
Motivation
What if your application was just a pure function? That's a very interesting
idea introduced by Cycle.js. Although the idea is nice,
the actual implementation of Cycle is not. The development is driven by vague
concepts such as "read/write effects" and "pureness" of the main
, resulting
inconsistency in driver implementations (even among the official ones!) and leaving
the real issues open - the entire framework is designed to create a "cycle" around
the application. However developers must still implement their own "sub-cycles"
and "isolation" inside their "pure apps".
Despite its implementation flaws, Cycle has some great concepts. Maintaining those concepts and implementing them properly is the goal of TSERS:
main
is just a signal transducerinput$ => output$
- Drivers encapsulate the side-effects
Model-View-Intent
instead ofIntent-Model-View
- No impure "isolation", just pure signal processing by using
filter
andmap
- Declarative and explicit
Hello world
The mandatory "Hello world" written with TSERS:
import {Observable as O} from "rx"
import TSERS as "@tsers/core"
import makeReactDOM as "@tsers/react"
const main = T => in$ => {
const {DOM: {h, prepare, events}, decompose, compose} = T
const [actions] = decompose(in$, "append$")
return intent(view(model(actions)))
function model({append$}) {
const msg$ = append$
.startWith("Tsers")
.scan((acc, s) => acc + s)
return msg$
}
function view(msg$) {
const vdom$ = msg$.map(msg =>
h("div", [
h("h1", msg),
h("button.add", "Click me!")
]))
return prepare(vdom$)
}
function intent(vdom$) {
const append$ = events(vdom$, ".add", "click").map(() => "!")
const loop$ = compose({append$})
const out$ = compose({DOM: vdom$})
return [out$, loop$]
}
}
const [Transducers, signal$, executor] = TSERS({
DOM: makeReactDOM("#app")
})
const { run }ย = Transducers
executor(run(signal$, main(Transducers)))
What is different compared to Cycle?
TSERS makes a clear distinction between signals and transducers:
Cycle's main
is:
const main = sources => sinks
Where sources
can be either streams or transducers or stream generators or
combination of those and sinks
are streams of signals going to the "outside world".
TSERS' main
is:
const main = Transducers => input$ => output$
Where Transducers
is always a collection of signal transducer functions,
input$
is a stream of events coming from the "outside world" and output$
is a
stream of signals going to the "outside world".
And same applies for drivers. Cycle driver is a function:
function driver(sink$) {
return sources
}
Where sink$
is a stream of signals coming from sinks and sources can be
either streams or transducers or stream generators or combination of those.
TSERS' main
driver is a function:
function driver() {
return [Transducers, signal$, executor: output$ => {}]
}
Where signal$
is a stream of signals coming from the "outside world",
Transducers
is a collection of transducer functions and executor
is
an interpreter that subscribes to the output$
signals (= sinks in Cycle)
and creates side-effects based on those signals.
Usage
TSERS provides only one public function via default
exports. That function takes
an object of drivers and returns an array containing Transducers
, signal$
and
executor
.
import TSERS from "@tsers/core"
import makeReactDOM from "@tsers/react"
import main from "./your-app"
const [Transducers, signals, executor] = TSERS({
DOM: makeReactDOM("#app")
})
Signals
Signals are just a stream of events coming from the "outside world". These events can be anything: user keyboard clicks, mouse movements, messages from WebSockets, sounds from guitar pedals etc.
The signal values are {key, val}
objects where val
contains the signal data
and key
is the name of the driver that emitted the signal (e.g. DOM
). It's
up to driver's implementation to decide whether it emits input signals or not -
some drivers might emit them (like web-socket driver) while others might not.
Transducers
Transducers are the "switch army knife" that actually processes the input signals
to output signals. Don't get distracted by the name: a transducer
is just a function that transforms signals a
to b
:
Transducer :: a$ => b$
As you can notice, main
is actually just another signal transducer.
Observable's map
, filter
and flatMap
(for example) are also transducers.
Transducers
(from TSERS
) is a JSON object that contains all transducers
from drivers, grouped by drivers name (e.g. if you are using DOM
driver then
you have for example Transducers.DOM.events
transducer).
TSERS provides also a small set of built-in transducers for common tasks.
The most important ones are: decompose
, run
and compose
.
decompose :: (in$, ...keys) => [{[key]: [signals-of-key]}, rest$]
As told before, input signals are just a stream of key-value pairs.decompose
is
a helper function meant to "extract" specific input signals from the rest.
const input$ = O.of({key: "Foo", val: "foo!"}, {key: "Bar", val: "bar"}, {key: "Foo", val: "foo?"}, {key: "lol", val: "bal"})
const [decomposed, rest$] = decompose(input$, "Foo", "Bar")
decomposed.Foo.subscribe(::console.log) // => "foo!", "foo?"
decomposed.Bar.subscribe(::console.log) // => "bar"
rest$.subscribe(::console.log) // => {key: "lol", val: "bal"}
compose :: ({[key]: [signals-of-key]}, rest$ = O.never()) => output$
compose
is the opposite of decompose
- it maps the given input values to the
{key,val}
pairs based on the input template and merges them. For convenience, it also
takes rest input signals (key-value pairs) as a second (optional) argument and
merges them to the final output stream.
const foo$ = O.just("foo!")
const bar$ = O.just("bar..")
const rest$ = O.just({key: "lol", value: "bal"})
const out$ = compose({Foo: foo$, Bar: bar$}, rest$)
out$.subscribe(::console.log) // => {key: "Foo", val: "foo!"}, {key: "Bar", val: "bar.."}, {key: "lol", val: "bal"}
Also note that compose
and decompose
are transitive:
const input$ = ...
const keys = [ ... ]
const output$ = compose(...decompose(input$, ...keys))
// output$ and input$ streams produce same values
run :: (input$, (input$ => [output$, loop$]) => output$
run
is the way to loop signals from downstream back to upstream. It takes
input signals and a transducer function producing output$
and loop$
signals
array - output$
signals are passed through as they are, but loop$
signals
are merged back to the transducer function as input signals.
Note that you can nest run
as much as you like! Before the loop$
signals are
merged to the input, they are "masked" with (ext=false
) key. This key ensures
that loop$
signals are "private": parent's loop$
signals can never appear
as its child's input$
.
run
accepts the return value in many formats: you can omit the second array
element and return only [output$]
without loop$
signals (equivalent to
[output$, O.never()]
. You can also return plain output$
stream which is equivalent
to [output$]
.
const input$ = compose({Foo: O.just("tsers")}, O.never(), true)
const main = input$ => {
const [{Bar: bar$, Foo: foo$}] = decompose(input$, "Bar", "Foo")
const output$ = bar$.map(x => x + "!")
const loop$ = compose({Bar: foo$.map(x => x + "?")})
return [output$, loop$]
}
const output$ = run(input$, main)
output$.subscribe(::console.log) // => "tsers?!"
Executor
executor
is like Cycle's run
but it doesn't make signal proxying from
output$
back to input$
(TSERS already has run
for it!). Its only task
is to subscribe to the output signals, interpret them and execute the
side-effects if necessary.
executor
also ensures that output signals are routed correctly to their
drivers' executors. Routing is done by using signal key
signals having key X
are routed to driver X
and so on.
const main = T => in$ => {
...
return compose({
DOM: vdom$,
WS: message$
})
}
const [T, signal$, execute] = TSERS({DOM: domDriver(), WS: wsDriver()})
// vdom$ events are routed to "DOM" driver's executor
// and message$ events are routed to "WS" driver's executor
execute(T.run(singnal$, main(T)))
executor
returns a dispose
function which can be called to dispose ("stop")
the execution:
const dispose = execute(output$)
setTimeout(dispose, 1000) // stop after 1 sec
Running the app
We know that:
signal$ = input$
main :: Transducers => input$ => output$
Tranducers.run :: (input$, input$ => [output$, loop$]) => output$
executor :: output$ => dispose
Let's compose those:
import TSERS from "@tsers/core"
import makeReactDOM from "@tsers/react"
import main from "./your-app"
const [Transducers, signal$, executor] = TSERS({
DOM: makeReactDOM("#app")
})
const { run }ย = Transducers
const dispose = executor(run(signal$, main(Transducers)))
Now you may understand why the signature of main
is Transducers => input$ => output$
:
it allows you to pass down the transducers and use them at the same time without partial
application or currying. It's all about composition. It's TSERS!
Model-View-Intent
The one major difference between TSERS and Cycle is that TSERS implements the real MVI
whereas Cycle implements IMV
. In practice this means that in Cycle apps, the border
of Model and Intent becomes blurry when there is a cross-dependency between
them. The simplest case is a form validation:
- In order to send the form to the server, you must have the form values (intent depends on model)
- In order to show an AJAX spinner during the validation, the send status must be stored to the form (model depends on intent)
If course that is solvable with IMV
and there are more or less elegant solutions
either leaking memory or not. In MVI
however, there is no exception - you can
always apply MVI
and loop the model dependencies back to input by using run
.
const main = T => in$ => {
const {DOM, HTTP, decompose, compose} = T
const [actions] = decompose(in$, "validate$", "validated$")
return intent(view(model(actions)))
function model({validate$, validated$}) {
const form$ = validate$.map(toShowSpinnerMod)
.merge(validated$.map(toAddValidationResultsAndRemoveSpinnerMod))
.startWith(someInitialValues)
.scan(applyMods)
.shareReplay(1)
return form$
}
function view(form$) {
return [form$, buildFormVDOM(form$)]
}
function intent([form$, vdom$]) {
const validate$ = DOM.events(vdom$, "button.validate", "click")
const validated$ = HTTP.req(form$.sample(validate$).map(toReqObject)).switch()
const out$ = compose({DOM: vdom$, value$: form$})
const loop$ = compose({validate$, validated$})
return [out$, loop$]
}
}
// index.js
executor(run(signal$, main(Transducers)))
Common Transducer API reference
TODO: examples
// compose :: ({[key]: [signals-of-key]}, rest$ = O.never()) => output$
// decompose :: (in$, ...keys) => [{[key]: [signals-of-key]}, rest$]
// extract :: (in$, key) => signals-of-key$
// == decompose(in$, key)[0][key]
// run :: (in$, (in$ => [out$, loop$])) => out$
// decomposeLatest :: (out$$, ...keys) => [{[key]: [signals-of-key]}, rest$]
const out$$ = form$.map(f => run(in$, Child(Transducers, f.childValue)))
const [{DOM, value$}, rest$] = decomposeLatest(out$$, "DOM", "value$")
// listDecomposeLatest :: (outArr$, (val => out$), ...keys) => [{[key]: [signals-of-key]}, rest$]
const persons$ = form$.map(f => f.persons)
const [{DOM, value$}, rest$] = listDecomposeLatest(persons$, person => run(in$, Person(Transducers, person)),
"DOM", "value$")
// DOM is now an array of latest DOM values of Person components
// value$ is now an array of latest values of Persons
License
MIT
Logo by Globalicon (CC BY 3.0)