Chapter 2 briefly touched on the fact that computations capture internal state. This chapter takes a deeper look at the primitives Bonsai provides for introducing and interacting with local state.
The simplest kind of state is
Bonsai.state
. It
returns both a value tracking
the state’s current model, and
also a function for updating
that model.
val state : (module Model with type t = 'model) -> default_model:'model -> ('model * ('model -> unit Effect.t)) Computation.t
This function has a few types that might be a bit confusing to a new user of OCaml, so let’s step through them one by one.
(module Model with type t = 'model)
:
This is a first-class module
that describes the type of the
state. The type needs to be
sexp
able and
equal
able.
Typically this argument is
provided by wrapping an already
existing module inside the
first-class-module-packing
syntax; for example, you might
write
(module String)
.default_model:'model
:
This is the initial value
contained in the state.Let’s break down a simple, yet realistic usage of this computation.
let textbox : (string * Vdom.Node.t) Computation.t =
let%sub state, set_state = Bonsai.state (module String) ~default_model:"" in
let%arr state = state
and set_state = set_state in
let view =
input
Vdom.Node.fun _ new_text -> set_state new_text))
~attr:Vdom.Attr.(value_prop state @ on_input (
()in
state, view ;;
The computation returns the current contents of a textbox, as well as the textbox view itself. The view could be combined with the views from other components, eventually becoming the view for the entire application. The “current value” could be passed on to other components (like we’ll do later).
let%sub state, set_state = Bonsai.state (module String) ~default_model:"" in
This line creates some string
state initially containing the
empty string. We use
let%sub
to
instantiate this state, giving
us access to state
and set_state
,
which have types
string Value.t
and
(string -> unit Effect.t) Value.t
,
respectively.
The let%arr
expression maps over two values
to produce a computation
containing the string and the
view. If we attempted to write
this code using
state
and
set_state
directly
instead of through
let%arr
, the
resulting program would not
type-check, since both of these
variables have
Value.t
types.
let%arr
is required
in order to get access to the
data inside the values.
The actual construction of
the textbox virtual-dom node is
quite boring; we add the
value_prop
property
to keep the textbox contents in
sync, and also register an event
handler for
on_input
, an event
that fires when the text in the
textbox changes.
When the event does fire, the
set_state
function
is called with the new string.
set_state
has type
string -> unit Effect.t
,
which you may recognize from the
last section in the virtual-dom
chapter. This function is called
with the new textbox contents,
and the event which is returned
schedules the state-setting in
the Bonsai event queue.
This is the payoff for the unanswered questions in the virtual-dom Chapter:
unit Effect.t
that aren’t just
Ignore
and
Many
:
State-transition functions
returned by stateful Bonsai
components will return
unit Effect.t
s.Bonsai.state_machine
)
can witness the changes made to
other stateful components, and
the Bonsai event-queue
guarantees that these updates
occur in a consistent order and
that downstream components
witness changes made to upstream
components.Now that we’ve built a single textbox component, let’s use it in a bigger component:
let two_textboxes : Vdom.Node.t Computation.t =
let%sub textbox_a = textbox in
let%sub textbox_b = textbox in
let%arr contents_a, view_a = textbox_a
and contents_b, view_b = textbox_b in
let display = Vdom.Node.textf "a: %s, b: %s" contents_a contents_b in
Vdom.Node.div
~attr:(Vdom.Attr.style (Css_gen.display `Inline_grid))
[ view_a; view_b; display ] ;;
This code is structurally very similar to the textbox component from earlier:
let%sub
(this
time with the
textbox
component
itself, rather than the
primitive
Bonsai.state
computation).let%arr
is used
to build a computation by
mapping over values previously
bound by
let%sub
.Of particular note is that
the textbox
component is instantiated twice
(using let%sub
).
Because of this, each textbox
will have its own independent
state.
Just for kicks, it’s easy to see what would happen if the computation is evaluated once but used twice. In the following code, the only difference between it and the previous example is this line:
- let%sub textbox_b = textbox in
+ let textbox_b = textbox_a in
let two_textboxes_shared_state : Vdom.Node.t Computation.t =
let%sub textbox_a = textbox in
let textbox_b = textbox_a in
let%arr contents_a, view_a = textbox_a
and contents_b, view_b = textbox_b in
let display = Vdom.Node.textf "a: %s, b: %s" contents_a contents_b in
Vdom.Node.div
~attr:(Vdom.Attr.style (Css_gen.display `Inline_grid))
[ view_a; view_b; display ] ;;
Not very useful, but heartwarming that something sensible happens at all.
While
Bonsai.state
is
quite useful, sometimes the
state contained within an
application more closely
resembles a state-machine with
well-defined transitions between
states.
Consider a “counter”
component that stores (and
displays) an integer, alongside
buttons which increment and
decrement that integer. This
component can easily be
implemented using
Bonsai.state
:
let state_based_counter : Vdom.Node.t Computation.t =
let%sub state, set_state = Bonsai.state (module Int) ~default_model:0 in
let%arr state = state
and set_state = set_state in
let decrement =
Vdom.Node.buttonfun _ -> set_state (state - 1)))
~attr:(Vdom.Attr.on_click ("-1" ]
[ Vdom.Node.text in
let increment =
Vdom.Node.buttonfun _ -> set_state (state + 1)))
~attr:(Vdom.Attr.on_click ("+1" ]
[ Vdom.Node.text in
"%d" state; increment ]
Vdom.Node.div [ decrement; Vdom.Node.textf ;;
But there’s a tricky bug hidden in this implementation: if a user clicks the button more than once within a span of 16-milliseconds, there’s a chance that both button clicks will set the same value! This is because the “current state” value is closed over by the event handler, and this value could be stale because the DOM (including event handlers) is only updated once per frame (approx every 16ms).
Fortunately,
Bonsai.state_machine0
is here to help! It has this
type:
val Bonsai.state_machine0
module Model with type t = 'model)
: (module Action with type t = 'action)
-> (
-> default_model:'model
-> apply_action:unit Effect.t)
(inject:('action -> unit Effect.t -> unit)
-> schedule_event:(
-> 'model
-> 'action
-> 'model)unit Effect.t)) Computation.t -> ('model * ('action ->
Compared to
Bonsai.state
, there
are several similarities:
default_model
).Computation.t
that
provides the current state
alongside a function which
schedules changes to the
state.The main difference is the
additional Action
module, and
apply_action
. The
apply-action parameter is a
function with a fairly long
signature, but can be simplified
down to the last section:
'model -> 'action -> 'model
.
This encodes the notion of a
state-machine transition: “given
the current model and an action,
produce a new model.”
So how would we use
state_machine0
to
fix the bug in the counter
application?
module Action = struct
type t =
| Increment
| Decrement
[@@deriving sexp_of]end
let counter_state_machine : Vdom.Node.t Computation.t =
let%sub state, inject =
Bonsai.state_machine0module Int)
(module Action)
(0
~default_model:fun ~inject:_ ~schedule_event:_ model action ->
~apply_action:(match action with
1
| Increment -> model + 1)
| Decrement -> model - in
let%arr state = state
and inject = inject in
let decrement =
Vdom.Node.buttonfun _ -> inject Decrement))
~attr:(Vdom.Attr.on_click ("-1" ]
[ Vdom.Node.text in
let increment =
Vdom.Node.buttonfun _ -> inject Increment))
~attr:(Vdom.Attr.on_click ("+1" ]
[ Vdom.Node.text in
"%d" state; increment ]
Vdom.Node.div [ decrement; Vdom.Node.textf ;;
First, an Action
module is defined as a sum type
that lists all the operations
that can be performed on the
state-machine. This module is
passed in to the call to
state_machine0
.
Then, the
apply_action
function is defined as a
model-transformation
function.
Using the computation
returned by
state_machine0
also
changes: instead of a
“set-state” function, we get a
function that “injects” our
Action.t
into a
unit Effect.t
.
Now, when a button is clicked
multiple times in quick
succession, instead of calling
set_state
multiple
times with the same value,
Bonsai will call
inject
multiple
times, and they’ll be processed
by apply_action
in
order, producing the correct
result.
UI programming is inherently stateful. Even a UI element as simple as a textbox needs to keep some state around to store the current contents of the textbox.
Sadly, many of the tools that functional programmers use for dealing with state almost exclusively involve moving that state out of their programs into a database, or by pulling mutable state out into a small part of the program. These strategies can keep the majority of programs relatively pure and easy to test, but sadly, they don’t scale well to UI components for a few reasons:
One way to look at UI components is that they are portals through which an application interacts with the messy world. The job of a component is to translate the unpredictable user actions into a well-understood piece of data.
Although the fact that components are stateful might injure your functional programming dogmatism, in fact, it is quite in line with functional programming principles, which aim to isolate effects. The most common way to isolate effects is by having a small kernel of effectful code invoke the pure majority of the logic; in other words, we isolate state by shifting it toward the root of the program. Bonsai offers an alternative tool for isolation. With Bonsai UI components, effectful code gets wrapped up and managed so that the interface provided by the component remains pure; in other words, we isolate state by shifting it toward the leaves of the program.
On to Chapter 4: Forms.