Awesome
TSERS
Transform-Signal-Executor framework for Reactive Streams (RxJS only at the moment... :disappointed:).
"tsers!"
Pronunciation: [tsers] (Also note that the /r/ is an alveolar trill, or "rolled r", not the postalveolar approximant ɹ̠ that is used in English and sometimes written similarly for convenience.)
Motivation
In the era of the JavaScript fatigue, new JS frameworks pop up like mushrooms after the rain, each of them providing some new and revolutionary concepts. So overwhelming! That's why TSERS was created. It doesn't provide anything new. Instead, it combines some old and well-known techniques/concepts and packs them into single compact form suitable for the modern web application development.
Technically the closest relative to TSERS is Cycle.js, but conceptually the closest one is CALM^2. Roughly it could be said that TSERS tries to combine the excellent state consistency maintaining strategies from CALM^2 and explicit input/output gates from Cycle - the best from both worlds.
Hello TSERS!
The mandatory "Hello world" applicatin with TSERS:
import {Observable as O} from "rx"
import TSERS from "@tsers/core"
import ReactDOM from "@tsers/react"
import Model from "@tsers/model"
function main(signals) {
const {DOM, model$: text$, mux} = signals
const {h} = DOM
const vdom$ = DOM.prepare(text$.map(text =>
h("div", [
h("h1", text),
h("button", "Click me!")
])))
const click$ = DOM.events(vdom$, "button", "click")
const updateMod$ = text$.mod(
click$.map(() => text => text + "!")
)
return mux({
DOM: vdom$,
model$: updateMod$
})
}
TSERS(main, {
DOM: ReactDOM("#app"),
model$: Model("Tsers")
})
Core concepts
TSERS
applications are built upon the three following concepts
- Signals flowing through the application
- Signal Transform functions transforming
input
signals intooutput
signals - Executors performing effects based on the
output
signals
Signals
Signals are the backbone of TSERS
application. They are the only way to
transfer inter-app information and information from main
to interpreters
and vice versa. In TSERS
applications, signals are modeled as (RxJS) observables.
- Observables are immutable so the defined control flow is always explicit and declarative
- Observables are first-class objects so they can be transformed into other observables easily by using higher-order functions
TSERS relies entirely on (RxJS) observables and reactive programming so if those concepts are not familiar, you should take a look at some online resources or books before exploring TSERS. One good online tutorial to RxJS can be found here.
TODO: muxing and demuxing
Signal transforms
Assuming you are somehow familiar with RxJS (or some other reactive library like Kefir or Bacon.js), you've definitely familiar with signal transform functions.
The signature of signal transform function f
is:
f :: (Observable A, ...params) => Observable B
So basically it's just a pure function that transforms an observable into
another observable. So all observable's higher order functions like map
,
filter
, scan
(just to name a few) are also signal transformers.
Let's take another example:
function titlesWithPrefix(item$, prefix) {
return item$
.map(it => it.title)
.filter(title => title.indexOf(prefix) === 0)
}
titlesWithPrefix
is also a signal transform function: it takes
an observable of items and the prefix that must match the item title and returns
an observable of item titles having the given prefix.
titlesWithPrefix :: (Observable Item, String) => Observable String
And as you can see, titlesWithPrefix
used internally two other signal
transform functions: map
and filter
. Because signal transform functions
are pure, it's trivial to compose and reuse them in order to create the
desired control flow from input
signals to output
signals.
If the signals are the backbone of TSERS
applications, signal transformers
are the muscles around it and moving it.
Executors
After flowing through the pure signal transformers, the transformed
output
signals arrive to the executors. In TSERS
, executors
are also functions. But not pure. They are functions that do nasty
things: cause side-effects and change state. That is, executors' signature
looks like this:
executor :: Observable A => Effects
Let's write an executor for our titles:
function alertNewTitles(title$) {
title$.subscribe(title => {
alert(`Got new title! ${title}`)
})
}
And what this makes executors by using the previous analogy... signals
flowing through the backbone down and down and finally to the... anus :hankey:.
Yeah, unfortunately the reality is that somewhere in the application you
must do the crappy part: render DOM to the browser window, modify the global
state etc. In TSERS
applications, this part falls down to executors.
But the good news is that these crappy things are (usually) not application
specific and easily generalizable! That's why TSERS
has the interpreter
abstraction.
Application structure
As told before, every application inevitably contains good parts and bad
parts. And that's why TSERS
tries to create an explicit border between
those parts: the interpreter abstraction.
The good (pure) parts are inside the signal transform function main
,
and the bad parts are encoded into interpreters.
Conceptually the full application structure looks like:
function main(input$) {
// ... app logic ...
return output$
}
interpreters = makeInterpreters()
output$ = main(interpreters.signals())
interpreters.execute(output$)
main
main
function is the place where you should put the application logic
in TSERS
application. It describes the user interactions and as a result
of those interactions, provides an observable of output signals that
are passed to the interpreters' executor functions.
That is, main
is just another signal transform function that receives
some core transform functions (explained later) plus input signals and
other transform functions from interpreters. By using those signals and
transforms, main
is able to produce the output signals that are consumed
by the interpreter executors.
Interpreters
Interpreters are not a new concept: they come from the Free Monad Pattern. In common language (and rounding some edges) interpreters are an API that separates the representation from the actual computation. If you are interested in Free Monads, I recommend to read this article.
In TSERS
, interpreters consist of two parts:
- Input signals and/or signal transforms
- Executor function
Input signals and signal transforms are given to the main
. They are
a way for interpreter to encapsulate the computation from the representation.
For example HTTP
interpreter provides the request
transform. It takes
an observable of request params and returns an observable of request observables
(request :: Observable params => Observable (Observable respose)
).
Now the main
can use that transform:
function main({HTTP}) {
const click$ = ....
const users$ = HTTP.request(click$.map(() => ({url: "/users", method: "get"})).switch()
// ...
}
Note that main
doesn't need to know the actual details what happens inside
request
- it might create the request by using vanilla JavaScript,
superagent
or any other JS library. It may not even make a HTTP request every
time when the click happens but returns a cached result instead! It's not
main
's business to know such things.
Some interactions may produce output signals that are not interesting in
main
. That's why interpreters have also possibility to define an executor
function which receives those output signals and interprets them,
(usually) causing some (side-)effects.
Let's take the DOM
interpreter as an example. main
may produce virtual
dom elements as output signals but it's not interested in how (or where)
those virtual dom elements are rendered.
function main({DOM}) {
const {h} = DOM
return DOM.prepare(Observable.just(h("h1", "Tsers!")))
}
What to put into main
and what into interpreters?
In a rule of thumb, you should use interpreter if you need to produce some effects. Usually this reduces into three main cases:
- You need to use Observable's
.subscribe
- you should never need to use that inside themain
- You need to communicate with the external world somehow
- You need to change some global state
Encoding side-effects into signal transforms or output signals?
In a rule of thumb, you should encode the side-effects into signal transform
functions if the input signal and the side effect result signal have a
direct causation, for example request => response
.
You should encode the side-effects into output signals and interpret them with
the executor
when the input there is no input => output causation (only
effects), for example VNode => ()
.
Why the separation of main
and interpreters?
You may think that the separation of main
and interpreters is just waste.
What benefit you get by doing that? The answer is that separating those
significantly improves testability, extensibility and the separation
of concerns of the application.
Imagine that you need to implement universal server rendering to your
application - just change the DOM
interpreter to server DOM
interpreter
that produces HTML strings instead of rendering the virtual dom to the
actual DOM. How about if you need to test your application? Just replace the
interpreters with test interpreters so that they produce signals your test
case needs and assert the output signals your application produces. How
about if you need to implement undo/redo? Just change the application state
interpreter to keep state revisions in memory. How about if you API version
changes? Just modify you API interpreter to convert the new version data
to the current one.
From theory to practice
Now that you're familiar with TSER's core concepts and the application structure, let's see how to build TSERS application in practice. This section is just a quick introduction. For more detailed tutorial, please take a look at the TSERS tutorial in the examples repository.
Starting the application
First you need to install @tsers/core
and some interpreters. We're gonna
use two basic interpreters: React DOM interpreter for rendering and Model
interpreter for our application state managing.
npm i --save @tsers/core @tsers/react @tsers/model
Now we can create and start our application. @tsers/core
provides
a function that takes the main
and the interpreters that are attached
to the application. The official interpreter packages provide always
a factory function that can be used to initialize the actual interpreter.
import TSERS from "@tsers/core"
import ReactDOM from "@tsers/react"
import Model from "@tsers/model"
function main(signals) {
// your app logic comes here!
}
// start the application with model$ and DOM interpreters
TSERS(main, {
DOM: ReactDOM("#app"), // render to #app element
model$: Model(0) // create application state model by using initial value: 0
})
Adding some app logic inside the main
Now we can use the signals and transforms provided by those interpreters,
as well as TSERS's core transform functions (see API reference below).
Interpreters' signals and transform functions are always accessible by their
keys. Also main
's output signals match those keys:
function main(signals) {
// All core transforms (like "mux") are also accessible
// via "signals" input parameter
const {DOM, model$, mux} = signals
const {h} = DOM
// model$ is an instance of @tsers/model - it provides the application
// state as an observable, so you can use model$ like any other observable
// (map, filter, combineLatest, ...).
// let's use the model$ observable to get its value and render a virtual-dom
// based on the value. DOM.prepare is needed so that we can derive user event
// streams from the virtual dom stream
const vdom$ = DOM.prepare(model$.map(counter =>
h("div", [
h("h1", `Counter value is ${counter}`),
h("button.inc", "++"),
h("button.dec", "--")
])))
// model$ enables you to change the state by emitting "modify functions"
// as out output signals. The modify functions have always signature
// (curState => nextState) - they receive the current state of the model
// as input and must provide the next state based on the current state
// Let's make modify functions for the counter: when increment button is
// clicked, increment the counter state by +1. When decrement button is clicked,
// decrement the state by -1
const incMod$ = DOM.events(vdom$, ".inc", "click").map(() => state => state + 1)
const decMod$ = DOM.events(vdom$, ".dec", "click").map(() => state => state - 1)
// And because the mods are just observables, we can merge them
const mod$ = O.merge(incMod$, decMod$)
// Finally we must produce the output signals. Because JavaScript functions
// can return only one value (observable), we must multiplex ("mux") DOM
// and model$ signals into single observable by using "mux" core transform
return mux({
DOM: vdom$,
model$: model$.mod(mod$)
})
}
Again: more detailed tutorial can be found from the TSERS examples repository.
What's different compared to Cycle?
If you read through this documentation, you might wonder that TSERS resembles Cycle very much. Technically that's true. Then why not to use Cycle?
Although the technical implementations of TSERS and Cycle are very similar, their ideologies are not. Cycle is strongly driven by the classification of read-effects and write-effects which means that drivers are not "allowed" to provide signal transforms encoding side-effects. Instead, all side effects must go to sinks and their results must be read from the sources, regardless of the causation of the side-effect and it's input.
Cycle's drivers are also meant for external world communications only, hence e.g. maintaining the global application state with drivers is not "allowed" in Cycle (although maintaining it with e.g. Relay driver is!!).
In practice, those features in Cycle result in some unnecessary symptoms like
the existence of isolation, usage of IMV
instead of MVI
(which works pretty well btw, until your intents start to depend
on the model), proxy subjects
usage, performance issues
and unnecessary complexity
whe sharing the state between parent and child components.
And those are the reasons for the existence of TSERS.
Core transforms API Reference
mux
JavaScript allows function to return only one value. That means that main
can
return only one observable of signals. However, applications usually produce
multiple types of signals (DOM, WebSocket messages, model state changes...).
That's why TSERS uses multiplexing to "combine" multiple types of signals into single observable. Multiplexing is way of combining multiple signal streams into one stream of signals so that different type of signals are identifiable from other signals.
The signature of mux
is:
mux :: ({signalKey: signal$}, otherMuxed$ = Observable.empty()) => muxedSignal$
mux
takes the multiplexed streams as an object so that object's keys represent the
type of the multiplexed signals. mux
takes also second (optional) parameter, that
is a stream of already muxed other signals (coming usually from the child components)
and merges it to output.
Usually you want to use mux
in the end of main
to combine all application
signals into single observable of signals:
function main({DOM, model$}) {
// ....
return mux({
DOM: vdom$,
model$: mod$
})
}
demux
De-muxing (or de-multiplexing) is the reverse operation for muxing: it takes an observable of the muxed signals, extracts the given signal types by their keys and returns also the rest of the signals that were not multiplexed
demux :: (muxedSignal$, ...keys) => [{signalKey: signal$}, otherMuxed$]
Usually you want to use this when you call child component from the parent component and want to post-process child's specific output signals (e.g. DOM) in the parent component:
const childOut$ = Counter({...signals, model$: childModel$})
const [{DOM: childDOM$}, rest$] = demux(childOut$, "DOM")
loop
loop
is a transform that allows "looping" signals from downstream back to upstream.
It takes input signals and a transform function producing output$
and loop$
signals
array - output$
signals are passed through as they are, but loop$
signals
are merged back to the transform function as input signals.
const initialText$ = O.just("Tsers").shareReplay(1)
const vdom$ = loop(initialText$, text$ => {
const vdom$ = DOM.prepare(text$.map(...))
const click$ = DOM.events(vdom$, "button", "click")
const updatedText$ = click$.withLatestFrom(text$, (_, text) => text + "!")
// vdom$ signals are passed out, updatedText$ signals are looped back to text$ signals
return [vdom$, updatedText$]
})
mapListById
Takes a list observable (whose items have id
property) and iterator function, applies
the iterator function to each list item and returns a list observable by using the return
values from the iterator function (conceptually same as list$.map(items => items.map(...))
).
- Item ids must be unique within the list.
- Iterator function receives two arguments: iterated item id and an observable containing the item and it's state changes
ATTENTION: iterator function is applied only once per item (by id
), although the
list observable emits multiple values. This enables some heavy performance optimizations
to the list processing like duplicate detection, cold->hot observable conversion and
caching.
TODO: example...
mapListBy
Same as mapListById
but allows user to define custom identity function instead of
using id
property. Actually the mapListById
is just a shorthand for this transform:
const mapListById = mapListBy(item => item.id)
demuxCombined
demuxCombined
has the same API contract as demux
but instead of bare output
signals, demuxCombined
handles a list of output signals. The name already
implies the extraction strategy: after the output signals are extracted by using
the given keys, their latest values are combined by using Observable.combineLatest
,
thus resulting an observable that produces a list of latest values from the
extracted output signals. Rest of the signals are flattened and merged by using
Observable.merge
so the return value of demuxCombined
is identical with
demux
(hence can be used in the same way when muxing child signals to parent's
output signals).
TODO: example...
Interpreter API reference
TODO: ...
License
MIT
Logo by Globalicon (CC BY 3.0)