Skip to content

Instantly share code, notes, and snippets.

@TyOverby
Last active September 26, 2022 13:51
Show Gist options
  • Save TyOverby/fa89d5c3ef9ef5830f0a5146da98ebd5 to your computer and use it in GitHub Desktop.
Save TyOverby/fa89d5c3ef9ef5830f0a5146da98ebd5 to your computer and use it in GitHub Desktop.

Never use Bonsai.Var.t inside an application

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!

External State: Bonsai.Var.t

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))

Internal State: Bonsai.state and friends

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 *)

Bad Practice: Using Var.t Inside a Component

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment