What is a "UI component" anyway? To me, "component" describes a grouping of code with the following properties:
- Abstraction: Users of the component shouldn't need to know about any implementation details
- Composition: It's right there in the name, components must compose! Usually stitching together two or more components will produce a new component.
- Encapsulation: A component shouldn't leak private details, nor should it require composition with other components in order to be useful
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
"
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.
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:
- We evaluate the predicate on the entire map twice:
once while computing the
~rows
parameter to the table component, and once inside thelet%arr
viaMap.count
. - The
let%arr
block is re-evaluated every time that anything in therows
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