Dynamism is central to engaging applications: as the state of the world changes, so should the UI.
The previous chapter
introduced an immutable view
type, Vdom.Node.t
along with the idea that the UI
is a function from data to view.
For large and dynamic input
data, this function is expensive
and must run quite often. To
keep up with quickly changing
data, we would like to only
re-compute the parts of the view
that depend on newly changed
data.
This chapter takes a detour from the theme of computing web UIs to investigate the core Bonsai abstractions. It may be surprising to know that Bonsai isn’t specialized for user interfaces; rather, it answers the very generic question of how to build composable incremental state-machines. As it turns out, incremental state-machines are a great abstraction for building UI!
Bonsai is all about
constructing incremental state
machine graphs. A
'a Value.t
is a
node in a graph that represents
a 'a
that changes
over time. A
'a Computation.t
is
an entire graph that might
contain many
Value.t
of
different types, but culminates
in a 'a Value.t
.
The motivation for having two
types will be thoroughly
explored later, but let us start
with something basic: building a
graph that computes a value that
depends on two other values.
let juxtapose_digits ~(delimiter : string) (a : int Value.t) (b : int Value.t)
string Computation.t
:
=let%arr a = a
and b = b in
Int.to_string a ^ delimiter ^ Int.to_string b ;;
The two phrases
a = a
and
b = b
may look a
little silly, but they are
necessary. The expression on the
right-hand side of both bindings
in the let%arr
has
type int Value.t
,
but the pattern on the left hand
side is a plain old
int
that we can
freely pass to
Int.to_string
. So
let%arr
is useful
for “unwrapping” the data inside
a Value.t
so that
we can access it for a limited
scope.
The type of the entire
let%arr
expression,
which includes the stuff on both
sides of in
, is
string Computation.t
rather than
string Value.t
.
This means that the result is a
graph and not a node in a graph.
To obtain the final node of a
Computation.t
graph, we can use a
let%sub
expression.
let _juxtapose_and_sum (a : int Value.t) (b : int Value.t) : string Computation.t =
let%sub juxtaposed = juxtapose_digits ~delimiter:" + " a b in
let%sub sum =
let%arr a = a
and b = b in
Int.to_string (a + b)in
let%arr juxtaposed = juxtaposed
and sum = sum in
" = " ^ sum
juxtaposed ^ ;;
We provide a computation and
let%sub
provides a
name we can use to refer to the
result node of that computation.
In the first
let%sub
above, the
computation is
juxtapose_digits a b
and the name is
juxtaposed
. The
important thing about using
let%sub
is that
juxtaposed
has type
string Value.t
, so
we can freely use it in
let%arr
expressions.
A subtle, yet extremely
important aspect of
let%sub
is that it
makes a copy of the input
computation, and the node that
the name refers to is the result
node of that copy, rather than
of the original. This means that
if you use let%sub
twice on the same computation,
you get access to the result
nodes for two independent copies
of the same graph. All we’ve
encountered so far are pure
function computations
constructed with
let%arr
, so having
multiple copies of a graph is
useless, since all the copies
will always be producing
identical results. The ability
to copy is useful when
computations contain internal
state.
The following example
demonstrates how to use
Bonsai.state
, a
primitive computation for
introducing internal state to a
computation. Notice that we get
access to two result nodes:
count
is the
state’s current value and
set_count
is a
function for updating that
value.
let (counter_button : Vdom.Node.t Computation.t) =
let%sub count, set_count = Bonsai.state (module Int) ~default_model:0 in
let%arr count = count
and set_count = set_count in
(* view-construction logic *)
Vdom.Node.divstring "Counter value: %{count#Int}"]
[ Vdom.Node.text [%
; Vdom.Node.buttonfun _ -> set_count (count + 1)))
~attr:(Vdom.Attr.on_click ("increment count" ]
[ Vdom.Node.text
] ;;
Now we can illustrate the
power of being able to
instantiate a component twice.
The following code demonstrates
that we can use
let%sub
on
counter_button
to
get three independent
counters.
let (three_counters : Vdom.Node.t Computation.t) =
let%sub counter1 = counter_button in
let%sub counter2 = counter_button in
let%sub counter3 = counter_button in
let%arr counter1 = counter1
and counter2 = counter2
and counter3 = counter3 in
Vdom.Node.div [ counter1; counter2; counter3 ] ;;
Every time we instantiate
counter_button
with
let%sub
, we get a
Vdom.Node.t Value.t
that represents the final result
node of a copy of the
counter_button
computation graph. We use
Vdom.Node.div
to
build a user interface that
contains all three buttons so
the user can click on them;
however, first we need to use
let%arr
to get
access to the view inside each
counter graph node.
The role of
let%sub
in Bonsai
is similar to the
new
keyword in an
object-oriented programming
language. Just like
new
makes a brand
new copy of the specified class
with its own independent mutable
fields, so also does
let%sub
make a
brand new copy of the specified
computation with its own
independent internal state. In
addition, just like
new
usually yields
a reference/pointer (in
languages like C# or Java)
instead of the data itself, so
also does let%sub
yield merely the result node of
the newly copied graph instead
of the graph itself.
We’ve introduced two basic
kinds of computations - state,
which may be introduced by
Bonsai.state
, and
work, which may be introduced by
let%arr
. While
these are certainly the most
important, Bonsai provides
primitive computations for a few
other things, such as
time-varying and edge-triggering
computations.
We’ve also introduced the
primary means by which you
construct larger computations
from smaller ones -
let%sub
. Part of
the learning curve of building
Bonsai apps is getting
comfortable composing together a
bunch of little
computations.
The previous section
intentionally did not explain
that Value.t
is an
applicative, which means that it
works with the
let%map
syntax, in
addition to the
let%arr
syntax
we’ve already introduced. The
difference between the two is
very small: let%arr
expands to the expansion of
let%map
, except it
wraps the entire thing in a call
to return
. The
following
let f (x : int Value.t) : int Computation.t = let%arr x = x in x + 1
expands to
let f (x : int Value.t) : int Computation.t = (let%arr x = x in x + 1)
which further expands to
let f (x : int Value.t) : int Computation.t = return (Value.map x ~f:(fun x -> x + 1))
The Value.t
applicative interface is scary
because re-using the result of a
let%map
expression
causes the work that it
represents to be duplicated.
Consider the following
computation.
let component (xs : int list Value.t) : string Computation.t =
let sum =
let%map xs = xs in
List.fold xs ~init:0 ~f:( + )
in
let average =
let%map sum = sum
and xs = xs in
let length = List.length xs in
if length = 0 then 0 else sum / length
in
let%arr sum = sum
and average = average in
string "sum = %{sum#Int}, average = %{average#Int}"]
[% ;;
We would like this
computation to only do the work
of computing sum
once; however, every usage of
sum
entails an
iteration through the list. Note
that the final result depends on
sum
directly, but
also indirectly through
average
; this means
that sum
is
computed twice in order to
produce the formatted
string.
This explanation seems to
contradict the explanation in
the beginning of this chapter
that computations are graphs and
values are nodes in the graph.
The truth is that values are
also graphs, and re-using a
value entails using another copy
of that value’s graph, thus
duplicating any work contained
in the graph. To avoid this work
duplication, we can instantiate
the value with
let%sub
, but since
let%sub
only
instantiates computations, we
must wrap the
let%map
inside a
call to return
. For
consistency and robustness,
we’ll apply this transformation
to average
as well,
even though it is only used
once.
let component (xs : int list Value.t) : string Computation.t =
let%sub sum =
returnlet%map xs = xs in
(List.fold xs ~init:0 ~f:( + ))
in
let%sub average =
returnlet%map sum = sum
(and xs = xs in
let length = List.length xs in
if length = 0 then 0 else sum / length)
in
returnlet%map sum = sum
(and average = average in
string "sum = %{sum#Int}, average = %{average#Int}"])
[% ;;
Before the introduction of
let%arr
, this was
the idiomatic way of using
Bonsai. However, now that
let%arr
exists, we
can transform the above code
into the following, exactly
equivalent, computation:
let component (xs : int list Value.t) : string Computation.t =
let%sub sum =
let%arr xs = xs in
List.fold xs ~init:0 ~f:( + )
in
let%sub average =
let%arr sum = sum
and xs = xs in
let length = List.length xs in
if length = 0 then 0 else sum / length
in
let%arr sum = sum
and average = average in
string "sum = %{sum#Int}, average = %{average#Int}"]
[% ;;
While the
Value.t
applicative
can have surprising behavior, if
you restrict yourself to only
use let%sub
and
let%arr
, then you
won’t ever accidentally
duplicate work.
Dynamic data flows into the
graph through
'a Var.t
, the third
main type in Bonsai. A var is
similar to a ref
or
the analogous
'a Incr.Var.t
from
incremental.
type 'a t
(** Creates a var with an initial value. *)
val create : 'a -> 'a t
(** Runs a function over the current value and updates it to the result. *)
val update : 'a t -> f:('a -> 'a) -> unit
(** Change the current value. *)
val set : 'a t -> 'a -> unit
(** Retrieve the current value. *)
val get : 'a t -> 'a
(** Get a value that tracks the current value, for use in a computation. *)
val value : 'a t -> 'a Value.t
The typical use-case for a
var is that there is some source
of ever-changing data, such as a
Polling_state_rpc
from a server. The Bonsai app
will subscribe to these changes
with a callback that updates the
var with the new data that it
received. The main app
computation then receives the
value-ified var after it has
been passed through
Var.value
. Here is
a concrete example:
let counter_every_second : int Value.t =
let counter_var : int Bonsai.Var.t = Bonsai.Var.create (-1) in
1.0) (fun () ->
every (Time_ns.Span.of_sec fun i -> i + 1));
Bonsai.Var.update counter_var ~f:(
Bonsai.Var.value counter_var
;;
let view_for_counter : Vdom.Node.t Computation.t =
let%arr counter = counter_every_second in
"counter: %d" counter
Vdom.Node.textf ;;
The Bonsai
library does not provide the
logic for stabilizing an
incremental function and
extracting the output value.
Instead, it compiles the value
and computation “surface syntax”
into the “assembly language”
provided by the
Incremental
library. Compilation happens
once when the app starts up, and
thereafter the main program only
interacts with the app in
Incr.t
form.
The Bonsai API is carefully
designed to allow its compiler
to statically analyze the entire
graph. This is why we don’t
provide bind, since the
callback passed to
bind
is an opaque
function. There are few
important consequences of the
static analyzability of Bonsai
graphs: