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:
- Performance: If you're computing both sides of the conditional even when only one is needed, then the other side is pure bloat.
- Semantics: It's easy to write some code in the
else_branch that throws exceptions if thecondition isn't met (e.g. ifcondisOption.is_none a, thenelse_should be able to include a call toOption.value_exn awithout worry. By computing both sides, it would be very hard to guard against these kinds of failures. - Side-effects: Components in Bonsai can trigger events to occur in a few situations:
- When the component is activated or deactivated
- When a
Value.tupdates to contain a new value - Every time that a frame is drawn.
These combinators can be found inside the
Bonsai.Edgemodule, and only run their side-effect when the component is being actively computed. Having two components outside of anif_vs having each as thethenandelsearguments 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.tassoc 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.