Awesome
TSERS Examples
Running the examples
git clone https://github.com/tsers-js/examples.git
cd examples
npm i && npm run examples
Tutorial
This tutorial is a quick walk through to TSERS
architecture and its best
practices (at least in author's opinion).
This tutorial assumes that you've read the TSERS core concepts and have understanding about RxJS. The purpose of this tutorial is not to give teach you to program with observables. There are many great introductions and full length books covering that topic already. One good online introduction can be found from here.
All tutorial codes can be found under the tutorial
folder. Running the
tutorials can be done by using terminal:
git clone https://github.com/tsers-js/examples.git
cd examples
npm i && npm run tutorial <tutorial-name> # e.g. npm run tutorial 01-hello-world
Happy learning!
Hello TSERS! Signals, transforms and interpreters
In TSERS, your application is divided roughly into two parts: signal transform function and interpreters.
The signal transform function, main
, contains the actual application logic
of your application. main
is just a pure function that takes input signals in and
transforms them into output signals. The purpose of main
is to define the application
logic and interactions in an explicit and declarative (functional) way so that the
application doesn't need to know about implementation details of low-level things
like DOM rendering or HTTP request formats,
Interpreters are the bridge between the application logic and computer: they read the application output signals and interpret them, causing effects like DOM rendering, interpreter's internal state changing or HTTP request sending. The purpose of the interpreters is to abstract the external world so that the application doesn't need to care whether e.g. it's virtual dom is rendered to the actual DOM (client) or to html string (server) or what kind of transport method (HTTP, WS, something else) is being used.
Signals are the backbone of TSERS applications. They are the only way to
transfer information between application, sub-applications and interpreters.
Signals in TSERS are modeled by using (RxJS) observables. Because JavaScript functions can
return only single value, all TSERS applications return also one stream of output signals
(= one observable). However, usually applications produce multiple types of signals (DOM,
WebSocket messages, model state changes...). That's why TSERS have two core concepts:
multiplexing and de-multiplexing.
Multiplexing (in future mux
ing) is merging multiple signal streams into one stream
of signals so that different type of signals are identifiable from other signals.
De-multiplexing (in future demux
ing) is the reverse process for mux
- it allows to
"extract" different type of signals from the muxed
stream (note that
demux(mux(signals)) == signals
).
But enough talk! Let's see how it looks like in code. Let's create a simple Hello World application with TSERS:
import {Observable as O} from "rx"
import TSERS from "@tsers/core"
import ReactDOM from "@tsers/react"
// define our application
function main(signals) {
const {DOM, mux} = signals
const {h} = DOM
const vdom$ = DOM.prepare(O.just(
h("div", [
h("h1", "Tsers!")
])))
return mux({
DOM: vdom$
})
}
// attach interpreters and start the app
TSERS(main, {
DOM: ReactDOM("#app")
})
Okay, lots of stuff there! Let's go it through line by line.
First we want to import the resources we need: RxJS Observable
functions, TSERS
runtime
(core) and DOM interpreter (that uses React
internally). The imported TSERS
and ReactDOM
are just normal JavaScript functions that can be used to initialize the application.
Then we define our application logic, the signal transform function main
. Note that
main
takes one parameter: (input) signals and transforms. The parameter is always an
object containing core transforms mux
and demux
and also signals/transforms from the
attached interpreters. Now because we are using DOM
interpreter, we have also DOM
in
the signals
. Each interpreter may define its own signals and transforms. DOM
interpreter
provides the following ones: h
, prepare
and events
.
In this example we are using h
and prepare
. h
is just a hyperscript helper function
that can be used to create virtual dom elements. prepare
is a function that "prepares"
the produced virtual dom stream so that it can produce events (described later) and so that
the DOM
interpreter understands the DOM output signals produced by the application.
Now that we know the meaning of h
and prepare
, we can create our vdom$
signal stream:
const vdom$ = DOM.prepare(O.just(h(...)))
Note that prepare
expects an observable so
we must wrap the virtual dom with Observale.just(...)
.
Finally we must return output signals from our application. This time we are lucky: we
have only DOM
type signals. However, we want to sure that our application scales later,
we must multiplex the output signals so that we can add signals with different type later.
Multiplexing can be done by using core transform function mux
that can be found from
the signals
parameter. The contract of mux
is simple: give the signals you want to
multiplex and place them into an object so that object keys represent the type of the
muxed signals. And that's it. Now we want to mux only DOM
type signals so we define an
object with DOM
as key and vdom$
signals as value and pass that object to mux
.
After everything else is done, we must start the application. That can be done by using
TSERS
function from the @tser/core
package. It takes two arguments: the application
main
function and interpreters that'll be attached to the application. The interpreters
are defined as an object where object keys identify the individual interpreters. Note that
we are using the same DOM
key to define our interpreter as we are using in our
application code. Behind that DOM
key, we are using ReactDOM
interpreter implementation:
ReactDOM
is a factory function that creates the actual interpreter that renders the
DOM under the given HTML element (#app
in this example).
And that's it! Your first application with TSERS!
User events as signals
Okay. UI application is not very useful without any user actions. Let's add some interaction to our application!
In TSERS, UI actions are nothing more that signal transformations: now that you have a virtual dom signals, wouldn't it be logical to derive some user event signals from those virtual dom signals? That's what we're doing next.
Remember that you "prepared" the vdom stream by using DOM.prepare
in the previous
section? Prepared vdom streams, you see, are able to emit user events by using
DOM.events
transform. DOM.events
takes three parameters: the prepared virtual
dom stream, CSS selector that must match the elements you want to listen and the
event type (e.g. click
, change
, input
...) you want to listen.
Let's add a button to our main
and listen to its click events:
function main(signals) {
const {DOM, mux} = signals
const {h} = DOM
const vdom$ = DOM.prepare(O.just(
h("div", [
h("h1", "Tsers!"),
h("button.btn", "Click me!")
])))
+ const click$ = DOM.events(vdom$, ".btn", "click")
return mux({
DOM: vdom$
})
}
Hmmm, nothing happened?! That's because we are not using those click events anywhere
in our application. Let's make those clicks to add !
to your message every time
when the button is clicked!
Now you may notice a problem: in order to get click$
, you must have vdom$
, but
in order to modify vdom$
you must have click$
. What to do? Luckily TSERS provides
another helper transform: loop
.
loop
is a way to loop signals from "end" back to "beginning". It takes two arguments:
input$
and loopFn
. input$
is the input signal stream that comes outside the loop.
loopFn
is the function that run inside the loop - it receives input$
signals as
parameter and must return an array [output$, loop$]
where output$
signals are passed
out from the loop and loop$
signals are brought back and merged to input$
signals.
Although the concept may sound difficult, the actual usage is not. Let's apply
loop
to our application:
function main(signals) {
const {DOM, mux, loop} = signals
const {h} = DOM
const initialText$ = O.just("Tsers").shareReplay(1)
const vdom$ = loop(initialText$, text$ => {
const vdom$ = DOM.prepare(text$.map(text =>
h("div", [
h("h1", text),
h("button", "Click me!")
])))
const click$ = DOM.events(vdom$, "button", "click")
const updatedText$ = click$
.withLatestFrom(text$, (_, text) => text + "!")
return [vdom$, updatedText$]
})
return mux({
DOM: vdom$
})
}
First we define the initial text and pass it to the loop as input. Then we can
use the input signals as text for our virtual dom. When clicks happen, they take
the latest value of the text and add !
into that value. The updated text signals
are looped back as text$
stream values so that the virtual dom is re-created
every time when the text changes. The created vdom$
stream is passed out from
the loop so that we can mux and return it to the interpreters.
Phew! That was a bit more complicated, wasn't it? If you didn't get it yet, don't panic. Read the section again few times and it'll begin to open. :smile: And don't worry about the complexity. This section was to introduce you some basic concepts of TSERS. The actual state handling in idiomatic TSERS can be done much easier way as you'll notice in the next part of this tutorial. :wink:
Putting the application state inside Model interpreter
The loop
function was a little bit overwhelming? I think so too. Fortunately
it's just signal processing so we can externalize it. :wink: And that's why TSERS
provides the Model interpreter fot the job! It basically does all that looping
stuff and provides a nice interface to access the state and to modify it.
Let's attach a model interpreter with some initial value:
import TSERS from "@tsers/core"
import ReactDOM from "@tsers/react"
import Model from "@tsers/model"
// ...
TSERS(main, {
DOM: ReactDOM("#app"),
model$: Model("Tsers") // use initial value "Tsers"
})
Listening the state changes
Now the state lives in the model interpreter model$
which is just an observable
that you can use like any other observable in TSERS:
function main(signals) {
const {DOM, model$: text$, mux} = signals
const {h} = DOM
const vdom$ = DOM.prepare(text$.map(text =>
h("div", [
h("h1", text)
])))
return mux({
DOM: vdom$
})
}
Not bad, huh? Let's go forward!
Modifying the state
Now you know how to listen to the state changes. Let's take a look how to modify it.
Model interpreter accepts state changes as modify output signals. A modify
output signal is an observable of functions currentState => newState
. But like
with DOM
interpreter, those modify functions must be prepared so that the model
interpreter can understand them. That's why model interpreter provides mod
transform that does the conversion. Let's make the text editable!
import {Observable as O} from "rx"
export default 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$
})
}
Looks very similar what you've already done with DOM
interpreter? It should
be, it's just signal processing - it's TSERS!
Although the previous example might look a bit overwhelming at the first glance, don't worry. Once you learn that, the hardest part is over. Basically all TSERS applications (regardless how complex they are) reduce into same primitive operations - knowing how to create simple applications with TSERS is knowing how to create complext apps as well!
Accessing sub-states by using lenses
Now you know how to create an application with TSERS. Let's see next, how to create complex applications by composing your "simple" TSERS applications.
Because TSERS applications are just pure signal transform functions, you can call them inside other signal transform functions (applications) like any other function! However, usually might you want the the "sub-application" sees only a part of the "global state" so that modifications to that sub-state reflect also to the global state.
That's why TSERS model interpreter provides the lens
transform. It creates
exactly same model interpreter instance as the parent model but so that the
"lensed model" sees only the lensed part of the state.
Internally lens
uses partial.lenses
.
In order to understand lenses better, you have to take a look at partial.lenses
docs.
For now you can just treat them like property getters const a = model$.lens("a")
.
Let's see how we could create two hello world texts that append !
every time
when the application's button is clicked.
First we have to change the initial state for our interpreter so that instead of string (text), it contains an object of two strings (texts):
TSERS(main, {
DOM: ReactDOM("#app"),
model$: Model({hello: "Hello", world: "Tsers"})
})
Then let's create new application main
and use our Hello.js
main implementation
so that we create a sub-model for both hello
and world
property and use them
as a model for the Hello
application:
import Hello from "./Hello"
function main(signals) {
const {DOM, model$, mux, demux} = signals
const {h} = DOM
const hello$ = Hello({...signals, model$: model$.lens("hello")})
const world$ = Hello({...signals, model$: model$.lens("world")})
const [{DOM: helloDOM$}, helloRest$] = demux(hello$, "DOM")
const [{DOM: worldDOM$}, worldRest$] = demux(world$, "DOM")
const rest$ = O.merge(helloRest$, worldRest$)
const vdom$ = DOM.prepare(O.combineLatest(helloDOM$, worldDOM$,
(helloDOM, worldDOM) =>
h("div", [
helloDOM, worldDOM
])))
return mux({DOM: vdom$}, rest$)
}
Okay, there are few new things that we haven't covered yet. First, notice that we
are using our Hello
like any other function: we pass the parent's input signals
to it but also override model$
signals so that the Hello
applications don't
see the whole parent state but only a part of it (hello
and world
properties).
The second new thing is demuxing. As you know, TSERS applications return a
stream of output signals. Hello
is no exception, so we can capture the output
signals to variables hello$
and world$
. However, those output signals contain
both DOM
signals and model$
signals. We are only interested in DOM
signals
(so that we can combine them and insert them into parent's vdom) so we need to
demux the output signals. TSERS provide a core function demux
that takes the
signal stream and list of keys ("DOM"
in the example) that'll be demuxed and
returns the demuxed signals as an object by their keys and rest of the signals
that were not demuxed. In ES6, you can destructure the return value so that you
don't need to keep any intermediate values (if you don't want).
The vdom$
construction part should have nothing new - we just combine the DOM
signals from the child components and use their latest values to construct the
parent's DOM output signals.
The last new thing is that we are passing rest$
stream as a second parameter to
the mux
transform. Yes. If you wouldn't, then the output signals (i.e. state
modifications) from child components would reach the interpreters, resulting that
nothing happens although the UI buttons are clicked.
Congrats! Your first composed "complex" application is ready! And as you can see,
there was no changes to the original Hello
component at all! That's the true
fractal architecture
by design! At this point, there is nothing new for you to learn: onwards, it's
just repeating these patterns and composing them in order to create bigger and
more complex applications.
Processing list states by using lenses (again)
Mapping a list model to an observable of output signals
Lists are a little bit more complicated thing... just kidding! With TSERS, list processing is almost as easy as processing a predefined number of nested components.
What is "list state"? It's an observable emitting events that contain arrays
with arbitrary number of items. If those items having an unique key (e.g. id
),
TSERS model interpreter provides mapListById
transform that makes the
list processing extremely easy.
Conceptually mapListById
is almost like (but bumped with steroids :muscle:):
list$.map(items => items.map(item => fn(item.id, item)))
The transform function that is passed to mapListById
should invoke some (child)
application function and return the output signals from the child application.
The transformer function receives item id as a first parameter and the lensed
item state as a second parameter.
The code is far more simpler than the explanation. Let's create a list of Hello world applications! First we need to modify the initial state of our model interpreter:
TSERS(main, {
DOM: ReactDOM("#app"),
model$: Model([
{id: nextId(), text: "Hello"},
{id: nextId(), text: "Tsers"}
])
})
function nextId() {
window.__ID = window.__ID || 0
return ++window.__ID
}
Note that list items must have an unique id
property, thus we're using
the nextId
helper function.
Now let's try to use mapListById
in order to render the Hello
sub-applications for those list items.
function main(signals) {
const {DOM, model$, mux} = signals
const {h} = DOM
const children$$ = model$.mapListById((id, item$) =>
Hello({...signals, model$: item$.lens("text")}))
// ...
}
Note that item$
is a model that can be passed to child application directly
as a model interpreter. Hello
application expects the model to be a string
(text value) so we need to get the text
property by using lens (should be
nothing new here, huh?).
Extracting values from output signal arrays
Well.. now you have an observable that contains a list of child application
output signals. But how to extract those signals? demux
doesn't work because
we are dealing list of signals. Luckily TSERS have another core transform
for this kind of situation: 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).
Let's see how to use demuxCombined
in practice and finish our example:
function main(signals) {
const {DOM, model$, mux, demuxCombined} = signals
const {h} = DOM
const children$$ = model$.mapListById((id, item$) =>
Hello({...signals, model$: item$.lens("text")}))
const [{DOM: childDOMs$}, rest$] = demuxCombined(children$$, "DOM")
const vdom$ = DOM.prepare(childDOMs$.map(childDOMs =>
h("div", [
...childDOMs.map((vdom, idx) =>
h("div", [
vdom,
h("button.rm", {"data-idx": idx}, "Remove")
])),
h("hr"),
h("button.add", "Add new greeting!")
])))
const addMod$ = DOM.events(vdom$, ".add", "click")
.map(() => items => [...items, {id: nextId(), text: "Tsers"}])
const rmMod$ = DOM.events(vdom$, ".rm", "click")
.map(e => Number(e.target.getAttribute("data-idx")))
.map(idx => items => items.filter((_, i) => i !== idx))
const mod$ = model$.mod(O.merge(addMod$, rmMod$))
return mux({
DOM: vdom$,
model$: mod$
}, rest$)
}
As you can see, using demuxCombined
is quite straightforward: it takes the result
of mapListById
as a first parameter and after that the "extracted" signal keys just
like demux
. The only difference is that the extracted streams contain arrays (like
childDOMs
) instead of scalar values.
The rest of the application should contain nothing new.
And that's it! Now you know how to create complex apps with TSERS. The rest is just composing and combining the basic cases we just covered. If you didn't get it now, don't worry - read this tutorial again (and again) and take a look at the other examples too. It might take some time to learn these all new things like (de)muxing, signals, lenses and modifications but it's definitely worth it!
If you have any more questions or problems, please join to the TSERS Gitter chat room. Let's continue the discussion there!
License
MIT