Bonsai encourages declarative UI construction. A computation is defined as a list of dependencies and a function which reads the current value of those dependencies, producing a new value. A computation defined in this way doesn’t care what the previous values of its dependencies were; it always operates on their current value.
However, sometimes it can be
helpful to witness a transition
from one value to another. In
Bonsai, we have the Bonsai.Edge
module, which has a collection
of functions which can notice
things like
and schedule Effects when they occur.
after_display
The main Edge
function we’ll take a look at is
Bonsai.Edge.lifecycle
,
which takes a number of optional
parameters of type
unit Effect.t Value.t
.
The first of these is
after_display
.
Edge.lifecycle
schedules the effect passed in
via after_display
as the last operation in the
Bonsai render-loop, right after
the DOM has been updated.
let frame_counter =
let%sub frames, set_frames = Bonsai.state (module Int) ~default_model:0 in
let%sub () =
Bonsai.Edge.lifecycle
~after_display:let%map frames = frames
(and set_frames = set_frames in
1))
set_frames (frames +
()in
let%arr frames = frames in
"this component has been alive for %d frames" frames
Vdom.Node.textf ;;
The text I chose for that component was very intentional. I wrote “this component has been alive for {n} frames” instead of “the application has been running for {n} frames”. This is because Edge functions only run if their computation is active. Let’s start with a demo, and then discuss what “active” means.
let frame_toggler =
let%sub showing, set_showing = Bonsai.state (module Bool) ~default_model:false in
let%sub output =
match%sub showing with
true -> frame_counter
| false -> Bonsai.const Vdom.Node.none
| in
let%arr showing = showing
and set_showing = set_showing
and output = output in
let toggle_showing = set_showing (not showing) in
let button_text = if showing then "disable counter" else "enable counter" in
let toggle_button =
Vdom.Node.buttonfun _ -> toggle_showing))
~attr:(Vdom.Attr.on_click (
[ Vdom.Node.text button_text ]in
output ]
Vdom.Node.div [ toggle_button; ;;
If you disable the component (wait a few seconds), you’ll notice that the counter picks up where it left off rather than continuing in the background.
As mentioned earlier,
after_display
only
runs when the computation is
“active”, and as this example
demonstrates, being inside of a
match%sub
is one
way to change the activity
status of a computation.
In fact, aside from
match%sub
, there’s
only one other combinator that
influences the active status:
Bonsai.assoc
.
Bonsai.assoc
is
used to build a dynamic number
of instances of a
computation.
Just like how
let%sub a = my_component in
let%sub b = my_component in
will create two
distinct instances of
my_component
, each
with their own state,
Bonsai.assoc
can
instantiate a dynamic number of
computations, one instantiation
per key-value pair from an
incoming
_ Map.t Value.t
.
I have a small library, Bonsai_web_ui_extendy,
which uses assoc
to
implement a component for easily
creating and deleting instances
of another component.
We’ll reuse the
frame_counter
component built in the first
example, and combine it with
extendy
to get
multiple
frame_counter
s.
Let’s see it in use:
let wrap_remove frame_counter remove =
let x_button =
fun _ -> remove)) [ Vdom.Node.text "x" ]
Vdom.Node.button ~attr:(Vdom.Attr.on_click (in
Vdom.Node.div [ x_button; frame_counter ]
;;
let many_frame_watches =
let%sub { contents; append; _ } = extendy frame_counter ~wrap_remove in
let%arr contents = contents
and append = append in
let append_button =
fun _ -> append)) [ Vdom.Node.text "add" ]
Vdom.Node.button ~attr:(Vdom.Attr.on_click (in
Map.data contents)
Vdom.Node.div (append_button :: ;;
By clicking on the “add”
button, we create multiple
frame-counters, each with their
own state, each which began
counting at the moment of their
creation. It might not be
obvious, but clicking on the
x
button not only
removes the component from the
view, but from the entire Bonsai
computation graph, so the
on_display
effect
is also stopped entirely.
on_activate
/
on_deactivate
The other two optional
parameters to
Bonsai.Edge.lifecycle
are on_activate
and
on_deactivate
, both
of which share the same type as
after_display
:
unit Effect.t Value.t
.
These effects are run whenever
the lifecycle computation
becomes active or inactive.
Let’s modify the lifecycle
component to use these new
functions. First, though, we’ll
want to do something when the
activation/deactivation occurs.
For that, I built a tiny logging
component which will let me
append a list of strings sent by
the frame_counter
component.
Ok, on to the extension of
frame_counter
:
let frame_counter (log : (string -> unit Ui_effect.t) Value.t) =
let%sub frames, set_frames = Bonsai.state (module Int) ~default_model:0 in
let%sub () =
Bonsai.Edge.lifecycle
~on_activate:let%map log = log in
(log "🚀")
~on_deactivate:let%map log = log in
(log "🔥")
~after_display:let%map frames = frames
(and set_frames = set_frames in
1))
set_frames (frames +
()in
let%arr frames = frames in
"this component has been alive for %d frames" frames
Vdom.Node.textf ;;
on_change
With the
lifecycle
function
as a primitive, we can implement
other useful edge-triggering
functions. One of these is also
included in the
Bonsai.Edge
module:
on_change'
.
on_change'
monitors a
'a Value.t
, and
when that value changes, it
calls a user-provided function,
giving that function both the
previous and current value. This
user-provided function returns
an Effect.t
, which
will be scheduled whenever the
value changes.
You currently have all the
tools to implement
on_change'
yourself, and you can find the
implementation here.
Combining the counter-component from Chapter 3 and the logging component that I used above, we can write a component which contains both a counter and a log, where the log is updated when the value changes.
let logging_counter =
let%sub log_view, log = logger in
let%sub counter_view, counter = counter in
let%sub () =
let callback =
let%map log = log in
fun prev cur ->
match prev with
None -> Ui_effect.Ignore
| Some prev -> log (if prev < cur then "🚀" else "🔥")
| in
module Int) counter ~callback
Bonsai.Edge.on_change' (in
let%arr log_view = log_view
and counter_view = counter_view in
Vdom.Node.div [ counter_view; log_view ] ;;
Declarative programs are easy
to reason about and test.
Extensive use of the
Edge
module will
make your program less and less
declarative.
Every time that you have the
opportunity, you should opt for
using anything other than an
Edge.*
function.
However, sometimes it’s
necessary, and we have testing
helpers to make your life a bit
easier when you do use edge
triggering. Because
after_display
runs
its effect, well, after the
display has occurred, how would
this interact with Bonsai
testing functions, like
Handle.show
?
To demonstrate, we’ll build
an awful Bonsai
component: a linear chain of
on_changes
:
let chain_computation =
let%sub a = Bonsai.const "x" in
let%sub b, set_b = Bonsai.state (module String) ~default_model:" " in
let%sub c, set_c = Bonsai.state (module String) ~default_model:" " in
let%sub d, set_d = Bonsai.state (module String) ~default_model:" " in
let%sub () = Bonsai.Edge.on_change (module String) a ~callback:set_b in
let%sub () = Bonsai.Edge.on_change (module String) b ~callback:set_c in
let%sub () = Bonsai.Edge.on_change (module String) c ~callback:set_d in
"a:%s b:%s c:%s d:%s"))
return (Value.map4 a b c d ~f:(sprintf ;;
Because
on_change
triggers
at the end of each frame, it
should take 4 frames to settle.
And indeed, in a unit test,
that’s exactly what we’ll
see:
let%expect_test "chained on_change" =
let handle = Handle.create (Result_spec.string (module String)) chain_computation in
Handle.show handle;
[%expect {| a:x b: c: d: |}];
Handle.show handle;
[%expect {| a:x b:x c: d: |}];
Handle.show handle;
[%expect {| a:x b:x c:x d: |}];
Handle.show handle;
[%expect {| a:x b:x c:x d:x |}];
Handle.show handle;
[%expect {| a:x b:x c:x d:x |}] ;;
But
Bonsai_web_test.Handle
has a function that makes this a
bit nicer:
recompute_view_until_stable
,
so we can rewrite the test in a
way that skips all the
intermediate frames:
let%expect_test "chained on_change with recompute_view_until_stable" =
let handle = Handle.create (Result_spec.string (module String)) chain_computation in
Handle.recompute_view_until_stable handle;
Handle.show handle;
[%expect {| a:x b:x c:x d:x |}] ;;
recompute_view_until_stable
is handy, but it’s hiding
intermediate states. If those
intermediate states allow for
logical bugs in your
application, then you might miss
them. As mentioned above: avoid
Edge
if you can;
it’s a sharp tool.