How to Compose Janestreet/Bonsai components?

Supposedly one of the advantages of Bonsai over Incr_Dom is better composition.

Having worked through most of the can-build-outside-Janestreet Bonsai examples + written a few components of my own, I feel I have no understanding of how composition works in Bonsai.

Suppose we have components: Apple, Orange, Cat, Dog.

In Bonsai parlance, we now have:

Apple.Action.t
Apple.Model.t
Apple.t = { model: Model.t, inject: Apple.Action.t list -> unit Effect.t }

Dog.Action.t
Dog.Model.t
Dog.t = { model: Dog.Model.t, inject: Dog.Action.t list -> unit Effect.t }

Orange.Action.t
Orange.Model.t
Orange.t = { model: Orange.Model.t, inject: Orange.Action.t list -> unit Effect.t }

Cat.Action.t
Cat.Model.t
Cat.t = { model: Cat.Model.t, inject: Cat.Action.t list -> unit Effect.t }

so using composition, we now build a component Widget.

How are we building Widget ? Are we:

  1. defining Widget.t in terms of Cat.t / Dog.t / Apple.t / Orange.t ?

  2. defining Widget.Model.t in terms of {Cat, Dog, Apple, Orange}.Model.t and Widget.Action.t in terms of {Cat, Dog, Apple, Orange}.Action.t — and throwing away Cat.t / Dog.t / Apple.t / Orange.t ?

What is an “idiomatic” full example of showing the “correct way” of doing composition in bonsai ?

Thanks!

1 Like

First and foremost, I think it’s worth noting that the basic building block of Bonsai is 'a Value.ts and 'a Computation.ts. The main idea behind composing those is simple:

If I want to use a 'a Computation.t, I use let%sub (or another operator), which instantiates a 'a Value.t. There’s a caveat: whatever code I’m now writing is contractually forced to return a 'b Computation.t.

Then, I can use let%arr to define a new 'b Computation.t in terms of a bunch of Value.ts.
That’s all there really is to composition in Bonsai. For some examples comparing to react, see GitHub - TyOverby/composition-comparison

In Bonsai, not every component needs to be defined in terms of Bonsai.state or Bonsai.state_machine0. In fact, most of your components will likely be computations composing other components, or using some other Bonsai utils.

That being said, I wouldn’t think of things as Dog.t, Apple.t, etc. When you define state components with Bonsai, you get a (Model.t, Action.t -> Effect.t) Computation.t. in other words, you get the incremental value of the state computation, and also a function you can use to tell the state to change (in adherence to your rigorously defined rules).

So how do we think about composing this? Well, presumably, you care about the value produced by your building block state machines. So you’ll use let%sub and let%arr to unpack and use those values. Also, since you’re not hardcoding these values, you expect them to change at some point. When might that happen?

  • On user interaction, in which you’d use the inject function in a virtual Dom effect handler
  • On a timer / lifecycle event, in which case you’d also use the inject function for a handler
  • triggered by other computations processing their own actions. In that case, you’d use the schedule_event argument inside that computation’s apply_action. And of course, you’ll need to pass your inject function to that component, either as an input (state_machine1), or as data given to the Action.t.

One of the upsides of Bonsai vs Incr_dom is that you don’t need to worry about these .ts; Bonsai will automatically do that for you behind the scenes on composition.

What you need to think about is what your components produce. If your Widget.t just packages the cat, dog, apple, orange together so they can be used by other computations, that’s fine. If it produces something else completely, such as Vdom.Node.t, that’s fine too.

That’s not necessary at all.

If you’re interested, I’ve finished the first draft of my snake game tutorial. It teaches the creation and composition of state machines. If you have time / interest in test driving it, I would really appreciate any feedback as to structure / format / content:

3 Likes

Thank you for your detailed reply. If you have time, I’d like work through the “parallel composition” example from TyOverby’s repo above. In particular:

Consider the following slight modification of the “parallel composition” code:

open! Core
open! Import

let app =
  let cnt_by_0_c  : _ Bonsai.Computation.t = Counter.Component~label (Value.return "cnt_0") () in
  let cnt_by_1_c  : _ Bonsai.Computation.t = Counter.Component~label (Value.return "cnt_1") () in
  let cnt_by_2_c  : _ Bonsai.Computation.t = Counter.Component~label (Value.return "cnt_2") () in
  ..
  let cnt_by_99_c  : _ Bonsai.Computation.t = Counter.Component~label (Value.return "cnt_99") () in
  let%sub cnt_0_v, _ = cnt_by_0_c in
  let%sub cnt_1_v, _ = cnt_by_1_c in
  ..
  let%sub cnt_99_v, _ = cnt_by_99_c in
  let%arr cnt_0 = cnt_0_v
  and cnt_1 = cnt_1_v 
  ..
  and cnt_99 = cnt_99_v 
  in
  N.div [ cnt_0; cnt_1; .. cnt_99 ]
;;

let () = Start.start app

Now, suppose counter_42 changes value.

How much work does Bonsai do ?

Does it build up old_tree = div w/ 100 childs, new_tree = div w/ 100 childs, and compares the two ?

If yes: how is this any cheaper than React ? It seems like we are building very big vdom trees and diffing them.

If no: given that the last function above composes Vdom.Node.t, not Vdom.Node.t Value.t, how does the system
understand that only counter_42’s div needs to be updated ?

Thanks!

Intuitively, the struggle I have here is:

  1. If – and this is a big If, If the ‘idiomatic’ way of doing things in Bonsai is:
let%sub ...
let%sub ... // go from _ Computation.t -> _ Value.t
let%sub ...
let%arr ...
and ...  // go from 'a Value.t to 'a
and ...
in
[
using the 'a, build a giant Vdom.Node.t
]

Then it’s really not clear to me why this is faster / better than React at all. It feels very much like “at the top/root node, collect all the Value’s, compute a giant vdom, then diff it”.

Philosophically, my main discomfort here is two seemingly contradictory ideas:

  1. the point of incremental is that we figure out the minimum amount of recompute and only do that work

  2. in bonsai, it appears we rebuild the entire vdom tree then diff, instead of just finding the part of the vdom that needs updating, and locally patch

I’m not sure if I am misunderstanding something, or if this is a reflection of reality.

In React, when something changes, pretty much everything is recalculated (except for useMemo). In Bonsai, when something changes, only downstream computations are recalculated. You can think of incremental computation as automatically useMemoing everything.

In your example above, when counter42 changes, Bonsai will recalculate it’s view, and that of the parent component. However, it will not recalculate the other 99 counter components that did not change. If those were more complicated than counter, this could present substantial performance savings.

Furthermore, if we’re working with data, and if that data needs to be processed / sorted / joined / etc before displaying it to users, a well-designed incremental computation would be extremely valuable.

It’s true that the higher-up “parent” components will probably be recalculated every time, but all they really do is wrap some pointers in a list, and wrap that in Vdom. These are very, very cheap operations.

All-in-all, if your computation is static and and flat with trivial child elements, there really isn’t anything worth optimizing. But as you build complex, large, dynamic, interconnected applications, which need to deal with data, Bonsai’s incrementality becomes quite valuable. And that’s not to mention the safety and maintainability benefits of the Elm architecture and OCaml’s type system.

If you have time, let’s walk through this in detail. We are no longer dealing with Counters, we have 100 Widgets (of the same type). Widgets are expensive to recompute, taking time T.

We update widget 42. What happens in this full rebuild ?

  1. We have to recompute widget_42; this takes time T. [1]

  2. Then we have to rebuild the parent of widget_42. How much does this cost? This has the cost of reading 100 Vdom.Node.t Value.t’s – the other 99 did not change and are cached. Reading Vdom.Node.t Value.t is also always some constant c, regardless of how complicated the widget’s output div is, because we just read the ref/ptr, we do not make a deep copy.

  3. So now, we have built new_dom_tree: Vdom.Node.t in time 100c + T – because we are not deep copying and using lots of sharing.

  4. Now the system has to diff (new_dom_tree: Vdom.Node.t) (old_dom_tree: Vdom.Node.t) – this also happens to be linear in SIZE OF DIFF, not SIZE OF TREE ? Why? Due to ref sharing, we don’t have to compare sub trees if they have the same ref.

^-- Is this how bonsai update efficiency is achieved in this case ? Although we do not to rebuild a new Vdom.Node.t, due to ref sharing it is both cheap to build and cheap to diff when the actual changeset is small.

[1] In theory, this could be less than T if Widgets have internal sharing and only a small part of the widget changed, but for simplicity, let us take the worst case cost of T.

With the disclaimer that I haven’t yet worked on Bonsai internals, that is my understanding.

It’s also worth noting that Bonsai’s incrementality applies to every Value.t, no matter how small. So even within a Widget.t Value.t, a change in one piece of state doesnt mean that the entire widget will recompute, just the parts of the computation DAG downstream of the change.

1 Like