Bonsai is a framework for building incremental state-machines, so a big part of programming with Bonsai is managing stateful values. Sometimes this state is external: some data changes on a server, and it needs to get into the client so that the UI can update. But other times, the state is internal to the UI, tracking things like "content of textbox A" or "which row is focused in the table."
There are two primitives in Bonsai for dealing with state, and they correspond exactly to this distinction between "external" and "internal" state, and using them in the wrong place can lead to some pretty confusing bugs!
Bonsai.Var.t
is intended to be used to inject values from outside of a Bonsai app into the computation. The API is quite simple
module Var : sig
type 'a t
val create : 'a -> 'a t
val set : 'a t -> 'a -> unit
val value: 'a t -> 'a Value.t
end
and is intended to be used like so:
val my_application : From_server.t Value.t -> Vdom.Node.t Computation.t
val some_empty_value : From_server.t
let from_server = Bonsai.Var.create some_empty_value in
don't_wait_for (
(* TODO: connect to server, get data, slam it into the var. *)
return ());
Bonsai_web.Handle.create
(my_application (Bonsai.Var.value from_server))
When building stateful components, you should use the Bonsai.state*
functions, which includes state
, state_machine0
, state_machine1
, and the less-common actor0
and actor1
. Let's start with Bonsai.state
because of its similarity to the Var
API.
val Bonsai.state
: (module Model with type t = 'model)
-> default_model:'model
-> ('model * ('model -> unit Effect.t)) Computation.t
Just like Var
, creating a state requires a default value that it will initially contain.
Unlike Var
, users must also pass in a first-class module with meta information about the type of the value tracked by state
. The module must implement this signature:
module type Bonsai.Model = sig
type t [@@deriving sexp, equal]
end
The value returned by state
is a Computation.t
wrapping the states "getter" and "setter". As we'll see soon, this is critical!
Using state to implement a reusable textbox component might look something like this:
let textbox: (string * Vdom.Node.t) Computation.t =
let%sub text_state =
Bonsai.state (module State) ~default_model:""
in
let%arr current_text, set_text = text_state in
let attr = Vdom.Attr.many
[ Vdom.Attr.on_change (fun new_text _ -> set_text new_text)
; Vdom.Attr.value_prop current_text
]
in
Vdom.Node.input ~attr []
It's important to note that the type (string * Vdom.Node.t) Computation.t
indicates that textbox
is a component template and not an instance of a component. That means that we can use it multiple times and each usage will have separate state:
let signup_form =
let%sub username = textbox in
let%sub password = textbox in
let%sub type_your_password_again_lol = textbox in
(* TODO: implement *)
let bad_textbox: (string * Vdom.Node.t) Computation.t =
let text_var = Bonsai.Var.create "" in
let%arr current_text = Bonsai.Var.value text_var in
let attr = Vdom.Attr.many
[ Vdom.Attr.on_change (fun new_text _ ->
Bonsai.Var.set text_var new_text;
Effect.Ignore)
; Vdom.Attr.value_prop current_text
]
in
Vdom.Node.input ~attr []
This definition of the textbox component has the same type as the one implemented with state
, but because it uses Var.t
all of the instances will share state!
let signup_form =
let%sub username = bad_textbox in
let%sub password = bad_textbox in
let%sub type_your_password_again_lol = bad_textbox in
(* TODO: implement *)
But Ty, couldn't I mint new vars for each instance of the component by making it take a unit parameter?
- let bad_textbox : (string * Vdom.Node.t) Computation.t =
+ let bad_textbox () : (string * Vdom.Node.t) Computation.t =
let signup_form =
- let%sub username = bad_textbox in
- let%sub password = bad_textbox in
- let%sub type_your_password_again_lol = bad_textbox in
+ let%sub username = bad_textbox () in
+ let%sub password = bad_textbox () in
+ let%sub type_your_password_again_lol = bad_textbox () in
(* TODO: implement *)
And, sure, that would work for this particular example, but the reality is that both Bonsai and users of Bonsai expect stateful Computation.t
to maintain independent state for independent usages.
For example, even with the "unit
-param-based component", it will misbehave inside of a Bonsai.assoc
:
val users : unit String.Map.t Value.t
val send_message : username:string -> message:string -> unit Effect.t
Bonsai.assoc (module String) users ~f:(fun username _ ->
let%sub message_box = bad_textbox () in
let%arr username = username
and message, textbox = message_box in
Vdom.Node.div [
Vdom.Node.textf "%s: " username;
textbox;
View.button ~on_click:(send_message ~username ~message) "send"
])
Notice that typing into one textbox will copy that text into all others. This is because Bonsai actually calls the f
parameter to assoc
exactly once in order to build an understanding of the computation. f
is not evaluated for each kv-pair.