State handling is probably the key aspect of any reactive web application, so fritz2 supports this with a simple, but
mighty concept: Store
s.
A store stores all your data for running an application; no matter if it is domain data or UI state. In contrast to other approaches, there might be an arbitrary number of stores, which you can connect to handle the overall state together.
Before we start, let us recap some model types, that are used for some upcoming examples:
// example of some value type
enum class Interest {
Programming,
Sports,
History,
WritingDocumentation
}
// example of an entity
data class Person(
val id: Int, // stable identifier
val name: String,
val interests: List<Interest>
)
The simplest way to create some store is to create one with the storeOf
factory:
// a store can be created anywhere in you application!
// pass some data as initial state into it
val storedInterest: Store<Interest> = storeOf(Interest.Programming)
// a store can manage any complex type `T`
val storedPerson: Store<Person> = storeOf(Person(1, "fritz2", 3, listOf(Interest.Programming)))
Once you have created the store, it can be used for...
This store supports the former functions by exposing the following two properties out of the box:
data
: This Flow
of T
can be used to render the current state into the UI by using some render*
-functions.
This is described in the reactive rendering sections of the
Render HTML chapter.update
: This is a Handler
which manages a state changes of the store. This particular handler takes one T
and
simply substitutes the "old" state T
of the store with it.You can use this handler to conveniently implement two-way data binding by using the changes
event-flow
of an input
-Tag
, for example:
val store: Store<String> = storeOf("")
render {
input {
// react to data changes and update the UI
value(store.data)
// react to UI events and update the data state
changes.values() handledBy store.update
}
}
changes
in this example is a Flow
of events created by listening to the Change
-Event of the underlying input-element.
Calling values()
on it extracts the current value from the input.
Whenever such an event is raised, a new value appears on the Flow
and is processed by the update
-Handler of the
Store
to update the model. Event-flows are available for
all HTML5-events.
There are some more convenience functions to
help you to extract data from an event or control event-processing.
In order to extend a store with own handlers or some specific data-flows, you can create your own store-object:
// use `RootStore` as base class! It implements all the functions you will rely on.
val storedInterests = object : RootStore<List<Interest>>(emptyList()) {
// fill with custom functionality
}
Of course, it is also possible to declare a class and create the object later:
class InterestsStore(initial: List<Interest> = emptyList()) : RootStore<List<Interest>>(initial) {
// fill with custom functionality
}
// use it
val storedInterests = InterestsStore()
Our custom store already has the handler update
which - as we already know - simply substitutes the store's "old" state
with a new one. This is not sufficient, when for example dealing with a list. It would be awkward to add some interest for
example.
For purposes like this, you can create your own handler inside some Store
scope with one of the handle
-factory
functions.
In this case we need the handle
function that takes a action
type-parameter,
which is simply our Interest
we want to add. Then the lambda function inside the handle
factory
has the following two parameters for returning the new state of the store:
state
of the storeaction
parameterWe can determine the new state simply by adding the new interest to the existing list:
val storedInterests = object : RootStore<List<Interest>>(emptyList()) {
val add: Handler<Interest> = handle { state: List<Interest>, action: Interest ->
if(state.contains(action)) state else state + action
}
}
We can craft a small UI, where the user can click on one item of the list of all existing interests to add one to his personal interests:
div { +"all interests:" }
ul {
Interest.values().forEach { interest ->
li {
+interest.toString()
clicks.map { interest } handledBy storedInterests.add
// ^^^^^^^^^^^^^^^^ ^^^^^^^^^ ^^^^^^^^^^^^^^^^^^
// map click to a connect the Flow with
// Flow of Interest the custom handler
}
}
}
div { +"selected interests:" }
ul {
storedInterests.data.renderEach { interest ->
li { +interest.toString() }
}
}
Besides custom handlers a custom store is also a perfect place to hold custom data
-flows, which do things like
mapping, filtering, sorting or other operations before the final result should get rendered.
Imagine you want to list the interests grouped by some criteria (which should in real world applications part of the model of course). You could add a property to your store, that does the filtering and mapping:
val storedInterests = object : RootStore<List<Interest>>(emptyList()) {
val noneProgramming: Flow<List<Interest>> = data // use the standard `data`-property as base
.filter { interest -> interest.any { it == Interest.History || it == Interest.Sports } }
.map { it.toString() }
val add = handle {...}
}
And then you can use it for your UI rendering:
div { +"selected none programming interests:" }
ul {
storedInterests.noneProgramming.renderEach { interest ->
li { +interest }
}
}
Of course, you could also do the intermediate operations in place, which might look like this:
div { +"selected none programming interests:" }
ul {
// Don't do this in real world!
storedInterests.data
.filter { interest -> interest.any { it == Interest.History || it == Interest.Sports } }
.map { it.toString() }
.renderEach { interest ->
li { +interest }
}
}
Even though this works, the intermediate operations clutter the UI declaration and make it harder to read. A second disadvantage is the absence of any meaningful naming; you have to understand all the filtering and mapping to understand, what would be rendered here. A property in a store always has a (meaningful) name.
As rule of thumb strive for dedicated properties inside a custom store, to bundle all the needed custom data-flows.
As fritz2 heavily depends on flows, introduced by kotlinx.coroutines, we want to look a little deeper into their concept and why we use them as important building block for the reactive rendering.
A Flow
is a time discrete stream of values.
Like a collection, you can use Flow
s to represent multiple values, but unlike other collections like List
s, for example,
the values are retrieved one by one. fritz2 relies on Flow
s to represent values that change over time and lets you react
to them (your data-model for example) .
A Flow
is built from a source which creates the values. This source could be your model or the events raised by an element,
for example. On the other end of the Flow
, a simple function called for each element collects the values one by one.
Between those two ends, various actions can be taken on the data (formatting strings, filtering the values, combining values, etc).
The great thing about Flow
s is that they are cold, which means that nothing is calculated before the result is needed.
This makes them perfect for fritz2's use case:
It uses flows for the "output" of a store by its data
-property and for
all HTML5-events.
They do not consume any memory or CPU load, until they get rendered: The mount-points consumes them and pull new values.
In Kotlin, there is another communication model called Channel
which is the hot counterpart of the Flow
.
fritz2 only uses Channel
s internally to feed the flows, so you should not encounter them while using fritz2.
To get more information about Flow
s, Channel
s, and their API,
have a look at the official documentation.
As you have already learned from the overview it often makes sense to write custom handlers for a dedicated task.
There are three different variants of handler factories available, which differ in the amount of parameters available in the handle expression:
Factory | Parameters in handle-expression | Use case |
---|---|---|
Store<T>.handle<Unit> |
state: T |
New state can only be generated from the old one or some external source. There is no information from the event source available. Typical use cases are resetting to initial state or clearing the store. |
Store<T>.handle<A> |
state: T , action: A |
New state can be based upon information passed from the event source. Typical use cases are updating some part of the overall state or adding or dropping a list item. The default handler update uses this, where its A is a T . |
Store<T>.handleAndEmit<A, E> |
state: T , action: A |
New state can be based upon information passed from the event source. Typical use cases are updating some part of the overall state or adding or dropping a list item. This handler ist a Flow itself one can emit some value E on. |
We will look at the first two of them here; the emitting-handler is rather an advanced topic and covered in a dedicated section there.
Have a look at our nested model example too.
Let's imagine some application that manages a list of persons. The application should allow to...
First we need a store for a List<Person>
, where we can create the needed handlers and some simple UI code, that will
render all persons:
val storedPersons = object : RootStore<List<Person>>(emptyList()) {
// here we will place our custom handlers
}
render {
section {
h1 { +"Persons:" }
ul {
storedPersons.data.renderEach { person ->
dl {
dt { +"Id:" }
dd { +person.id.toString() }
dt { +"Name:" }
dd { +person.name }
dt { +"Interests:" }
dd { +person.interests.joinToString() }
}
}
}
}
}
Now we can implement the first requirement: Add a new person.
In order to do so, we will need a new Person
-object to be passed into the handler. Inside the handle-code we can then
add this person to the existing list, if the object is not already present in the list to avoid duplicates.
Thus, we need the typical handle
-factory, which accepts a so-called action parameter. This could be an arbitrary
type A
. The existing default handler update
uses the T
as action, as it simply substitutes the old state by the
passed new object of the same type. But an action can be any type and is not limited to anything.
In our case the type T
of the store is List<Person>
, but as action it is sufficient to have a single
Person
-object.
val storedPersons = object : RootStore<List<Person>>(emptyList()) {
val addPerson: Handler<Person> = handle { persons, newPerson ->
// ^^^^^^^ ^^^^^^^ ^^^^^^^^^
// defines the the the new
// type of parameter "old" person
// to pass state to add
if (persons.any { it.id == newPerson.id }) persons else persons + newPerson
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
// use the "old" state and the action add the action to the
// to check for duplicates old state to create the new state
}
}
The above handler-code shows some typical pattern: We create the new store's state by analyzing first the "old" state with some information from the action and then decide whether the old state could remain or there must be some update also using some information from the action.
Let's "simulate" some UI, that uses this handler:
val storedPersons = object : RootStore<List<Person>>(emptyList()) {
val addPerson: Handler<Person> = handle { persons, newPerson ->
if (persons.any { it.id == newPerson.id }) persons else persons + newPerson
}
}
render {
// (rendering of person omitted)
section {
button {
+"Add Fritz"
clicks.map {
// we define some static person; in real life this might be created by some user input
Person(1, "Fritz", setOf(Interest.Programming, Interest.History))
} handledBy storedPersons.addPerson
// in order to call `addPerson`, we need a `Flow<A>` as first parameter for `handledBy`
// So the flow defines the action `A`, that the handle-code will receive,
// in this case some `Person`-object
}
}
}
In order to implement the second requirement, which is to add some interest to some specific person, we should
recap the shape of our model: Person
is an entity, so it has some stable identifier. To determine the person in
the list, which some new interest should be added to this person's interests list, we simply can rely on the
Person.id
-property.
So our action consists of two parts:
For simple cases like this, a Pair
is a sufficient choice to group both information. But of course you could also
create some (data) class or any other kotlin feature that fits.
val storedPersons = object : RootStore<List<Person>>(emptyList()) {
val addPerson: Handler<Person> = {...}
val addInterest: Handler<Pair<Int, Interest>> = handle { persons, (idForUpdate, newInterest) ->
// ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
// combine meta-information destructure meta-information
// with information and information
// as action parameter for expressive naming
persons.map { person ->
if (person.id == idForUpdate) person.copy(interests = person.interests + newInterest) else person
// ^^^^^^^^^^^ ^^^^^^^^^^^
// use meta-information to determine the specific use the information portion
// person that must get an update from action to update the person
}
}
}
render {
// (rendering of person omitted)
section {
button {
+"Make Fritz write Documentation"
clicks.map {
// create the Pair of meta-information and information
1 to Interest.WritingDocumentation
} handledBy storedPersons.addInterest
}
}
}
The last task is quite easy: It should be possible, to clear the complete list of users.
We can set an empty list as new store's state without any further information. Thus, the handler we must create does not need any action parameter at all.
fritz2 offers a special variant for cases like this, where the action type is Unit
.
val storedPersons = object : RootStore<List<Person>>(emptyList()) {
val addPerson: Handler<Person> = {...}
val addInterest: Handler<Pair<Int, Interest>> = {...}
val clear: Handler<Unit> = handle { emptyList() }
}
render {
// (rendering of person omitted)
section {
button {
+"Clear Persons"
clicks handledBy storedPersons.clear
}
}
}
Don't be fooled: Inside the handler-code the "old" state would be available; we simply do not need it for our implementation. Just to make this more explicit, look at this:
val clear: Handler<Unit> = handle { persons ->
console.log("Dropped list of ${persons.size} persons...")
emptyList()
}
There are other use-cases where the access to the old state is definitely needed, and you always have access therefore.
Just to show you the final result en block:
val storedPersons = object : RootStore<List<Person>>(emptyList()) {
val addPerson: Handler<Person> = handle { persons, newPerson ->
if (persons.any { it.id == newPerson.id }) persons else persons + newPerson
}
val addInterest: Handler<Pair<Int, Interest>> = handle { persons, (idForUpdate, newInterest) ->
persons.map { person ->
if (person.id == idForUpdate) person.copy(interests = person.interests + newInterest) else person
}
}
val clear: Handler<Unit> = handle { emptyList() }
}
render {
section {
h1 { +"Persons:" }
ul {
storedPersons.data.renderEach { person ->
dl {
dt { +"Id:" }
dd { +person.id.toString() }
dt { +"Name:" }
dd { +person.name }
dt { +"Interests:" }
dd { +person.interests.joinToString() }
}
}
}
}
section {
button {
+"Add Fritz"
clicks.map {
Person(1, "Fritz", setOf(Interest.Programming, Interest.History))
} handledBy storedPersons.addPerson
}
button {
+"Make Fritz write Documentation"
clicks.map {
1 to Interest.WritingDocumentation
} handledBy storedPersons.addInterest
}
button {
+"Clear Persons"
clicks handledBy storedPersons.clear
}
}
}
If you need to purposefully fire an action somewhere inside a RenderContext
or a Store
you can directly invoke
a Handler
:
//call handler with data
someStore.someHandler(someAction)
//call handler without data
someStore.someHandler()
It is possible in fritz2 to create some handler without any store.
Common use cases are the combination of multiple handler calls based upon the same event or to simply create a working placeholder before the store is ready.
The syntax is simple: Just pass an expression of type (A) -> Unit
to the handledBy
-function, where A
is the
inner type of the flow.
Look at this example:
section {
button {
+"Remove Fritz"
clicks.map { 1 } handledBy { id ->
// `removePerson` handler is not ready yet; but UI should be checked to work
console.log("Would delete person with id=$id")
}
}
}
Sometimes creating a custom store is kinda overkill or there are certain situations, where it is simply more feasible to rely on store-mapping which implies there is no custom store available.
In those cases - often in some small local code area - it is totally ok, to customize a standard store created by
storeOf
-factory.
val storedPerson = storeOf(Person(1, "fritz2", emptySet()))
// define handler on some store object
val addInterest = storedPerson.handle<Interest> { person, interest ->
person.copy(interests = person.interests + interest)
}
render {
button {
+"Make fritz write documentation"
clicks.map { Interest.WritingDocumentation } handledBy addInterest
}
}
As the handler
-factory functions are extension functions, it is possible to extend a "closed" store-object.
Most real-world applications contain multiple stores which need to be linked to properly react to model changes.
To make your stores interconnect, fritz2 allows calling Handlers
of others stores directly with or
without a parameter.
object SaveStore : RootStore<String>("") {
val save = handle<String> { _, data -> data }
}
object InputStore : RootStore<String>("") {
val input = handle<String> { _, input ->
SaveStore.save(input) // call other store`s handler
input // do not forget to return the "next" store state!
}
}
Have a look at our nested model example too.
Sometimes you may want to keep the history of states in your store, so you can navigate back in time to build an undo-function or maybe just for debugging...
fritz2 offers a history
factory to do so.
val store = object : RootStore<String>("") {
val history = history()
}
By default, you synchronise the history with the updates of your store, so each new state will be added to the history automatically.
Calling history(synced = false)
, you can control the content of the history
by manually adding new entries. Call push(entry)
to do so.
You can access the complete history via its data
attribute as Flow<List<T>>
or by using current
attribute which returns a List<T>
. For your convenience history
also offers
Flow<Boolean>
called available
representing if entries are available (e.g., to show or hide an undo button)back()
method to get the latest entry and remove it from the historyclear()
method to clear the historyFor a store with a minimal undo function you just have to write:
val storedData = object : RootStore<String>("") {
val hist = history()
// your handlers go here (add history.clear() here where suitable)
val undo = handle { hist.back() }
}
render {
div("form") {
input {
value(storedData.data)
changes.values() handledBy storedData.update
}
storedData.hist.available.render {
if(it) {
button("btn") {
+"Undo"
}.clicks handledBy storedData.undo
}
}
}
}
When one of your handlers contains a long-running action (like server-call, etc.) you might want to keep the user informed that something is going on.
Using fritz2 you can use the tracker
factory to implement this:
val storedData = object : RootStore<String>("") {
val tracking = tracker()
val save = handle { model ->
tracking.track() {
delay(1500) // do something that takes a while
"$model."
}
}
}
render {
button("btn") {
className(storedData.tracking.data.map {
if(it) "spinner" else ""
})
+"save"
clicks handledBy storedData.save
}
}
The service provides you with a boolean Flow
representing the state of the running transaction.
Beware that you are responsible for handling exceptions, if you use an unsafe operation within the tracking scope. The tracker is safe in the way, that it will catch any escaped exception, then stops the tracking, and finally it will rethrow the former.
If you want your tracking to continue instead, just handle exceptions within the tracking scope.
In cases where you don't know which Handler
of another store will handle the data exposed by your handler, you can use
the EmittingHandler
, a type of handler that doesn't just take data
as an argument but also emits data on a new Flow
for other handlers to receive.
Create such a handler by calling the handleAndEmit<T>()
function instead of the usual handle()
function and add the
offered data type to the type brackets:
val personStore = object : RootStore<Person>(Person(...)) {
val save = handleAndEmit<Person> { person ->
emit(person) // emits current person
Person(...) // return a new empty person (set as new store state)
}
}
The EmittingHandler
named save
emits the saved Person
on its Flow
.
Another store can be setup to handle this Person
by connecting the handlers:
val personStore = ... //see above
val personListStore = object : RootStore<List<Person>>(emptyList<Person>()) {
val add = handle<Person> { list, person ->
console.log("add new person: $person")
list + person
}
init {
// don't forget to connect the handlers
personStore.save handledBy personListStore.add
}
}
After connecting these two stores via their handlers, a saved Person
will also be added to the list
in personListStore
. All dependent components will be updated accordingly.
To see a complete example visit our
validation example which uses connected
stores and validate a Person
before adding it to a list of Person
s.
If you need a handler's code to be executed whenever the model is changed,
you have to use the drop(1)
function on a Flow
to skip the initialData
:
val store = object : RootStore<String>("initial") {
init {
data.drop(1) handledBy {
console.log("model changed to: $it")
}
}
}
By using the ad-hoc handledBy
function here your store gets not updated after new data arrives.