Skip to content

Instantly share code, notes, and snippets.

@TyOverby
Created January 14, 2023 15:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save TyOverby/daf9a92db08d1c724f298bfb943f5a3e to your computer and use it in GitHub Desktop.
Save TyOverby/daf9a92db08d1c724f298bfb943f5a3e to your computer and use it in GitHub Desktop.

Bonsai Patterns

"Components"

What is a "UI component" anyway? To me, "component" describes a grouping of code with the following properties:

  1. Abstraction: Users of the component shouldn't need to know about any implementation details
  2. Composition: It's right there in the name, components must compose! Usually stitching together two or more components will produce a new component.
  3. Encapsulation: A component shouldn't leak private details, nor should it require composition with other components in order to be useful

Components in Bonsai

It's tempting to try ascribing a type to the abstract notion of a component, but I've found that the most accurate description for the "type" of a component is a recursively defined definition:

  • A Computation.t is a component
  • Any OCaml function that returns a component is a component

Which means that the following signatures are all "components":

val sidebar : Vdom.Node.t Computation.t

val textbox : is_password_textbox:bool -> (string * Vdom.Node.t) Computation.t

val graphic : Data.t Value.t -> Vdom.Node.t Computation.t

val table 
  :  ?first_column_is_sticky:bool
  -> columns:Column.t list 
  -> ('k, Row.t, _) Map.t Value.t 
  -> ('k option * Vdom.Node.t) Computation.t

This recursive definition of a component can't be given a proper type in OCaml, but if you hear someone talk about "a Bonsai component", then you just need to know that it's "something that eventually builds a Computation.t"

sub, sub, sub, sub, arr!

Because components are usually defined in terms of other components, it's common for a component definition to start with a chain of let%sub on its subcomponents, followed by a let%arr to do some work on the values produced by those subcomponents.

let my_textbox = 
  let%sub textbox_state = Bonsai.state (module String) ~default_model:"" in 
  let%arr current_text, set_text = state_and_inject in 
  let attr = Vdom.Attr.many 
    [ Vdom.Attr.on_input (fun _ new_text -> set_text new_text)
    ; Vdom.Attr.value_prop current_text
    ] in
  current_text, Vdom.Node.input ~attr:[] ()

In this example, we build a state component and immediately use it, but it scales up to components built of many subcomponents, like this piece of code from the component gallery:

let make_demo (module M : Demo) = 
  let%sub view, demo = M.view in
  let%sub ocaml_codemirror = codemirror ~content:demo in
  let%sub html_codemirror = html_renderer view in
  let%sub rendered_or_html = Form.Elements.radio_button ... in
  let%sub display_which = ... in
  let%arr ocaml_codemirror = ocaml_codemirror
  and display_which = display_which
  and rendered_or_html = rendered_or_html in
  Vdom.Node.div [ ocaml_codemiror ; ... ]

In this example, 5 sub-components are used to define the "make_demo" component, and you can see that the results of earlier components are used as inputs to later components, such as the the output of the M.view component being passed to the codemirror component. Finally, the results of the subcomponents are read using let%arr and used to produce the output for the supercomponent.

"Pure" components

Most components are stateful, they might contain subcomponents like Bonsai.state, and Bonsai.state_machine, or reference other high-level subcomponents that make use of the state primitives. However, because components in Bonsai recompute their values incrementally, building a stateless component is an easy way share the computation of a value. As an example, let's take a look at poorly optimized component:

val My_table.component : rows:Row.t Row_id.Map.t Value.t -> Vdom.Node.t Computation.t

let table_with_summary 
    ~(rows : Row.t Row_id.Map.t Value.t) 
    ~(predicate : (Row.t -> bool) Value.t) = 
  let%sub table = 
    My_table.component 
      ~rows:(let%map rows = rows 
             and predicate = predicate in 
             Map.filter rows ~f:predicate)
  in
  let%arr rows = rows and predicate = predicate and table = table in 
  let total_row_count = Map.length rows in 
  let filtered_row_count = Map.count rows ~f:predicate in
  Vdom.Node.div 
    [ table
    ; Vdom.Node.textf "showing %d out of %d rows" filtered_row_count total_row_count
    ]

If we care about performance (maybe the predicate function is expensive), then this implementation has a few issues:

  1. We evaluate the predicate on the entire map twice: once while computing the ~rows parameter to the table component, and once inside the let%arr via Map.count.
  2. The let%arr block is re-evaluated every time that anything in the rows map changes, even if it's not something that would influence the output of the component.

Let's take a stab at rewriting it so that there's more sharing:

let table_with_summary 
    ~(rows : Row.t Row_id.Map.t Value.t) 
    ~(predicate : (Row.t -> bool) Value.t) = 
  let%sub filtered_rows = 
    let%arr rows = rows and predicate = predicate in 
    Map.filter rows ~f:predicate
  in
  let%sub table = My_table.component ~rows:filtered_rows in
  let%arr rows = rows 
  and filtered_rows = filtered_rows 
  and predicate = predicate 
  and table = table in 
  let total_row_count = Map.length rows in 
  let filtered_row_count = Map.length filtered_rows  in
  Vdom.Node.div 
    [ table
    ; Vdom.Node.textf "showing %d out of %d rows" filtered_row_count total_row_count
    ]

By defining filtered_rows, and using it for both the input to the table component and our own summary view, we only perform the work once. However, this solution is still not great; if the predicate is expensive, even if we're only calling Map.filter once, the predicate is still being evaluated on every element of the map whenever the input map or predicate changes. Ideally, we'd only run the predicate on rows that are new or have changed since the last evaluation. Because rows is a map, we can do this with a pair of functions defined by Bonsai: Bonsai.assoc and Bonsai.Map.filter_mapi.

val Bonsai.assoc 
  :  ('k, 'v, 'cmp) Bonsai.comparator 
  -> ('k, 'v, 'cmp) Map.t Value.t
  -> ('k Value.t -> 'v Value.t -> 'r Computation.t) 
  -> ('k, 'r, 'cmp) Map.t Computation.t

val Bonsai.Map.filter_mapi 
  :  ('k, 'v, 'cmp) Map.t Value.t
  -> f:(key:'k -> data:'v -> 'r option)
  -> ('k, 'r, 'cmp) Map.t Computation.t

let%sub filtered_rows = 
  let%sub filtered_as_option = Bonsai.assoc (module Row_id) rows ~f:(fun _key data -> 
    let%arr data = data and predicate = predicate in 
    if predicate data then Some data else None)
  in
  Bonsai.Map.filter_mapi filtered_as_option ~f:(fun ~key:_ ~data -> data)
in
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment