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 thecond
ition isn't met (e.g. ifcond
isOption.is_none a
, thenelse_
should be able to include a call toOption.value_exn a
without 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.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 anif_
vs having each as thethen
andelse
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.