Skip to content

Instantly share code, notes, and snippets.

@TyOverby
Created February 4, 2023 21: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/cf9b79bab1cf96369411c761c9406d95 to your computer and use it in GitHub Desktop.
Save TyOverby/cf9b79bab1cf96369411c761c9406d95 to your computer and use it in GitHub Desktop.

Higher-order components

Now that you know what a component is, let's complicate things! If a component is "any ocaml value that returns a Computation.t", then a "higher-order component" is a component where one of the inputs is a component. Using this definition, some of the APIs exposed by Bonsai itself would qualify as higher-order components. Although not used very frequently, we export an "if" combinator defined like so:

val Bonsai.Let_syntax.Let_syntax.if_
  :  cond:bool Value.t 
  -> then_:'a Computation.t 
  -> else_:'a Computation.t 
  -> 'a Computation.t 

if returns a Computation.t, so you could classify it as a "component". It also takes at least one component as an input, so it also qualifies as a "higher-order component." This is just like how functions that takes functions as input (e.g. List.map) are sometimes referred to as "higer-order functions."

But why does if_ take its then_ and else_ parameters as Computation.t? Couldn't it take those args as Value.t like so:

val Bonsai.Let_syntax.Let_syntax.if_
  :  cond:bool Value.t 
  -> then_:'a Value.t 
  -> else_:'a Value.t 
  -> 'a Computation.t 

And yes, that function is quite easy to implement, but with a Value.t-based if_ combinator, it assumes that you're already fully computing both the then_ and else_ branches and the only thing that if_ does is pick one to return.

Because if_ takes its parameters in Computation.t form, it's able to selectively activate only the component selected by the conditional bool. This distinction matters for a few reasons:

  1. Performance: If you're computing both sides of the conditional even when only one is needed, then the other side is pure bloat.
  2. Semantics: It's easy to write some code in the else_ branch that throws exceptions if the condition isn't met (e.g. if cond is Option.is_none a, then else_ should be able to include a call to Option.value_exn a without worry. By computing both sides, it would be very hard to guard against these kinds of failures.
  3. Side-effects: Components in Bonsai can trigger events to occur in a few situations:
    • When the component is activated or deactivated
    • When a Value.t updates to contain a new value
    • Every time that a frame is drawn. These combinators can be found inside the Bonsai.Edge module, and only run their side-effect when the component is being actively computed. Having two components outside of an if_ vs having each as the then and else arguments would mean the difference between both components being active (and producing side-effects) at the same time, vs only having one active component.

Bonsai.assoc is another higher-order component. It has this type:

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

assoc is a component (because it produces a computation), and its last argument is a component (because it produces a computation), so assoc is also considered to be higher-order.

Just like if, the motivation for making assoc higher-order is that it's a control-flow primitive. When assoc is evaluated with an input Map.t Value.t, it builds a wholely independent instance of the component that it is parameterized over for each key/value pair in the input map, returning the results of those components in the output map. So if the input map contains one item, then there will be one component instantiated with the key and value passed to the ~f function. But things get interesting with more than one kv-pair; because each entry produces an independent component, they each instance will have separate internal state.

So far, all the examples of higher-order components have been functions inside Bonsai, and this is no coincidence: the main use of higher-order components is to manipulate control flow, selectively evaluate components, and maintain component state, which is basically just a description of the Bonsai library. However, there are reasons to build your own higher-order components, let's take a look at one now in the context of a hypothetical modal dialogue component.

We want our modal component to implement state-tracking for whether the modal is open and if it is, builds a view containing some user-provided UI that has been extended with a border, a title, and a button for closing the modal. A first pass at the component might look like this:

type t = 
  { view : Vdom.Node.t
  ; open_modal: unit Effect.t
  }

val modal : content: Vdom.Node.t Value.t -> t Computation.t

let modal ~content = 
  let%sub modal_state = Bonsai.state (module Bool) ~default_model:false in 
  let%arr is_open, set_is_open = modal_state 
  and title, content = title_and_content in 
  let view = 
    if not is_open 
    then Vdom.Node.empty 
    else 
    let close_button = 
      Vdom.Node.button 
        ~attr:(Vdom.Attr.on_click (fun _ -> set_is_open false)) 
        [ Vdom.Node.text "close" ] 
    in
    Vdom.Node.div ~attr:(Vdom.Attr.class_ "my-modal-component")
      [ Vdom.Node.h1 [ "it's a modal!" ; close_button ]
      ; user_content
      ]
  in
  { view; open_modal = set_is_open true }
     

But the modal defined like this has a pretty big issue: it takes the "modal content" as a Value.t, meaning that the user of the component needs to be computing it even if the modal is closed! This is very similar to the issue with the naieve if_ implementation discussed above, and if we take the same approach of "higher-orderifying" modal, then we can solve the problem in a very similar way!

type t = 
  { view : Vdom.Node.t
  ; open_modal: unit Effect.t
  }

val modal : content: Vdom.Node.t Computation.t -> t Computation.t

let modal ~content = 
  let%sub is_open, set_is_open = Bonsai.state (module Bool) ~default_model:false in 
  let%sub open_modal = 
    let%arr set_is_open = set_is_open in 
    set_is_open true
  in
  match%sub is_open with
  | false ->
    let%arr open_modal = open_modal in 
    { view = Vdom.Node.none ; open_modal}
  | true ->
    (* only instantiate [content] here  in the [true] branch *)
    let%sub content = content in 
    let%arr content = content 
    and open_modal = open_modal in 
    let view = 
      let close_button = 
        Vdom.Node.button 
          ~attr:(Vdom.Attr.on_click (fun _ -> set_is_open false)) 
          [ Vdom.Node.text "close" ] 
      in
      Vdom.Node.div ~attr:(Vdom.Attr.class_ "my-modal-component")
        [ Vdom.Node.h1 [ title ; close_button ]
        ; user_content
        ]
    in
    { view; open_modal }

By having the modal component take content as a Computation, we're able to give modal control over when the component is evaluated; in this case, only when the modal is open.

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