Composition Comparison
What is this?
This repository houses a collection of UI-framework compsition challenges.
It identifies a few patterns for how composing components, and implements them
using Bonsai, Elm, and React
Is this supposed to be unbiased?
Not really
- I'm pretty good at Bonsai, pretty bad at Elm, and my React knowledge is
quite outdated
- I designed Bonsai with automatic component composition as a primary goal
01 - Components
What is a "UI component" anyway? To me, a component describes a group of code
with the following properties:
- It serves as an abstraction — Users of the component shouldn't need
to know about any implementation details
- It's built to be composed with other components — It's right there
in the name! Components must be robust to being combined with one another
- Encapsulated — A component shouldn't leak private details, nor should
it require composition with other components in order to be useful.
For the rest of this series, we're going to be looking at ways to compose
a very basic "counter" component.
Defining a component
The counter component that we'll be building is a basic widget that maintains a
count (as an integer), and two buttons, one to decrease the stored value, and
another to increase it.
Our counter components will be configurable in two ways:
- The user of the counter component can pick a text label to display
- The caller can also pick the delta by which the increment and decrement
buttons will modify the stored value
The user of the component will also probably want access to the value in
some way, which the component should also provide.
| Bonsai 2023 |
Bonsai |
Elm |
React |
open! Core
open! Import
let apply_action _ctx by model = function
| `Incr -> model + by
| `Decr -> model - by
;;
let component ~label ?(by = Bonsai.return 1) graph =
let state, inject = Bonsai.state_machine1 ~default_model:0 ~apply_action by graph in
let view =
let%map state and inject and by and label in
let button op action =
N.button ~attr:(A.on_click (fun _ -> inject action)) [ N.textf "%s%d" op by ]
in
N.div
[ Vdom.Node.span [ N.textf "%s: " label ]
; button "-" Decr
; Vdom.Node.span [ N.textf "%d" state ]
; button "+" Incr
]
in
view, state
;;
|
open! Core
open! Import
module Action = struct
type t =
| Incr
| Decr
[@@deriving sexp_of]
end
let apply_action ~inject:_ ~schedule_event:_ by model = function
| Action.Incr -> model + by
| Decr -> model - by
;;
let component ~label ?(by = Value.return 1) () =
let%sub state_and_inject =
Bonsai.state_machine1 (module Int) (module Action) ~default_model:0 ~apply_action by
in
let%arr state, inject = state_and_inject
and by = by
and label = label in
let button op action =
N.button ~attr:(A.on_click (fun _ -> inject action)) [ N.textf "%s%d" op by ]
in
let view =
N.div
[ Vdom.Node.span [ N.textf "%s: " label ]
; button "-" Decr
; Vdom.Node.span [ N.textf "%d" state ]
; button "+" Incr
]
in
view, state
;;
|
module Counter exposing (Model, Msg, init, update, view)
import Browser
import Html exposing (Html, div, span, text)
import Html.Events exposing (onClick)
type alias Model =
Int
init : Model
init =
0
type Msg
= Increment
| Decrement
update : Int -> Msg -> Model -> Model
update howMuch msg model =
case msg of
Increment ->
model + howMuch
Decrement ->
model - howMuch
view : Int -> String -> Model -> Html Msg
view howMuch label model =
let
button op action =
Html.button [ onClick action ] [ text (String.concat [ op, String.fromInt howMuch ]) ]
in
div []
[ text (String.concat [ label, ": " ])
, button "-" Decrement
, text (String.fromInt model)
, button "+" Increment
]
|
import React from 'react';
export const defaultState = 0;
export function applyAction(state, action, by) {
switch (action) {
case 'increment':
return state + by;
case 'decrement':
return state - by;
default:
console.error('BUG');
}
}
const Counter = ({ label, by, state, inject }) => {
let increment = () => inject('increment');
let decrement = () => inject('decrement');
return (
<div>
{label}:<button onClick={decrement}> -{by}</button>
{state}
<button onClick={increment}> +{by}</button>
</div>
);
};
export default Counter;
|
|
The definition of this component shows 4 improvements over the Bonsai of today:
- Using primitives involves less boilerplate, no longer do you need first-class
modules to call the basic stateful component constructors
- Instead of building
Bonsai.Computation.t components are functions that go
from local_ Bonsai.graph to 'a Bonsai.t
- #2 allows us to compute and return the view separately from the state, allowing
better incrementality
- let-punning. This isn't a bonsai-improvement, I just think it's nice to be
able to write
let%map a in foo a instead of let%map a = a in foo a
|
This Bonsai component is exposed to users through the `component` function.
You'll notice that we use regular OCaml functions to pass properites to the
component, like `~label` and the optional `?by` parameters.
The component function produces a component that yields both the view and
the counter value. You'll also notice that in defining Counter.component, we
use Bonsai.state_machine1. state_machine1 is a primitive component that we
use to build our bigger component. The 1 indicates that the state machine has
access to one input value that it can read when processing an action.
|
This elm component is in the shape of a whole module which exports its initial model,
transition function, and view calculations separately for the user to compose. Notice
how the update function takes an integer to determine how much the state should be
increased or decreased by, and how the view function also requires that value in addition
to a string to use for the label.
|
If you're used to React, this code might be a bit confusing at first. Clearly the
counter component is stateful, so why is it exporting a default state and a
state-machine transition function instead of bundling a call to useState inside
the component?
Firstly, useState would be buggy, two clicks of the button on the same
rendering frame would act like it had only been clicked once, so we'd actually
want to pick useReducer.
But beyond that, component-local state like useState and useReducer is truly
component-local, with no way to export that state to other components like we'd
need in the "sequential" and "multiplicity" sections. Moreover, component-local
state vanishes when the component is unmounted, making it useful only for state
that you want to be transient.
This implies that when a component manipulate state that other pieces of the
application care about, that state needs to be stored and manipulated outside
of the compoennt. This is usually done by either using a state-management system
like Redux, or by pushing the state into the nearest common ancestor component
of any subcomponents that need to read or write to that state. We'll be using
the latter approach in order to avoid excess boilerplate.
|
Since Bonsai was originally modeled after the Elm Architecture, it shouldn't
be super surprising that defining a component looks similar in both languages.
Both focus on the state for a component being modeled as a state-machine, with
explicit model and and action types which correspond to states and state
transitions.
Using a component
The "topmost" component in an application is sometimes referred to as the
"application component". These can be as big or as small as necessary, so let's
start out with the smallest app-component possible: a single counter.
| Bonsai 2023 |
Bonsai |
Elm |
React |
open! Core
open! Import
let app graph =
let view, _ = Counter.component ~label:(Bonsai.return "counter") graph in
view
;;
let () = Start.start app
|
open! Core
open! Import
let app =
let%sub view, _ = Counter.component ~label:(Value.return "counter") () in
return view
;;
let () = Start.start app
|
module Main exposing (main)
import Browser
import Counter
update =
Counter.update 1
view =
Counter.view 1 "counter"
main =
Browser.sandbox { init = Counter.init, update = update, view = view }
|
import React, { useReducer } from 'react';
import ReactDOM from 'react-dom';
import Counter, { applyAction, defaultState } from '../../shared/Counter';
const App = () => {
let [state, inject] = useReducer(
(state, action) => applyAction(state, action, 1),
defaultState
);
return <Counter label="counter" by={1} state={state} inject={inject} />;
};
ReactDOM.render(<App />, document.getElementById('app'));
|
|
Not much is different between 2023 Bonsai and current-bonsai, but the the let%sub
syntax is removed, and the call to return, which used to convert between Bonsai.Value.t
and Bonsai.Computation.t is no longer necessary.
|
Because the application component is an instance of our counter component, we need
to invoke the component-generating function with its required parameters. Because
we're fine with the default by argument being 1, we only need to provide the value
for the label.
|
The Elm component requires passing all the configuration to all the different pieces
of the component separately. Make sure that you keep both of the by values in sync!
|
Because our components can't manage their own state, the top-level application
component is where the call to useReducer can be found, the results of which
are passed on to the counter component.
|
02 - Parallel Composition
"Parallel Composition" is a term that I came up with for describing the style of
composing components that are completely isoloated from one another. Not only are
these components visually separated, they're also logically separated, removing or
changing one would have no impact on the other.
| Bonsai 2023 |
Bonsai |
Elm |
React |
open! Core
open! Import
let app graph =
let first, _ = Counter.component ~label:(Bonsai.return "first") graph in
let second, _ = Counter.component ~label:(Bonsai.return "second") graph in
let%map first and second in
N.div [ first; second ]
;;
let () = Start.start app
|
open! Core
open! Import
let app =
let%sub first, _ = Counter.component ~label:(Value.return "first") () in
let%sub second, _ = Counter.component ~label:(Value.return "second") () in
let%arr first = first
and second = second in
N.div [ first; second ]
;;
let () = Start.start app
|
module Main exposing (main)
import Browser
import Counter
import Html exposing (Html, div)
type alias Model =
{ first : Counter.Model, second : Counter.Model }
init : Model
init =
{ first = Counter.init, second = Counter.init }
type Msg
= First Counter.Msg
| Second Counter.Msg
update : Msg -> Model -> Model
update msg model =
case msg of
First msg_first ->
{ model | first = Counter.update 1 msg_first model.first }
Second msg_second ->
{ model | second = Counter.update 1 msg_second model.second }
view : Model -> Html Msg
view model =
div []
[ Html.map First (Counter.view 1 "first" model.first)
, Html.map Second (Counter.view 1 "second" model.second)
]
main =
Browser.sandbox { init = init, update = update, view = view }
|
import React, { useReducer } from 'react';
import ReactDOM from 'react-dom';
import Counter, { applyAction, defaultState } from '../../shared/Counter';
const App = () => {
let [state1, inject1] = useReducer(
(state, action) => applyAction(state, action, 1),
defaultState
);
let [state2, inject2] = useReducer(
(state, action) => applyAction(state, action, 1),
defaultState
);
return (
<div>
<Counter label="first" by={1} state={state1} inject={inject1} />
<Counter label="second" by={1} state={state2} inject={inject2} />
</div>
);
};
ReactDOM.render(<App />, document.getElementById('app'));
|
|
let%sub is gone, conversion between types is gone, and
we're using let-punning.
|
For Bonsai, we use let%sub to create new instances of the component,
and then let%arr to compose the views produced by those instances.
|
In Elm there's some more boilerplate involved. The application-component
is now bigger, and that means that we need a new model and action type to
go along with it.
Just like in the Bonsai example, we need to compose the views of these
components manually (there's no way around this if you want precise control
of the view). However, we also need to call Html.map, which is used to
transform the type of the message produced by the view.
More apparent is our need to implement an update function which dispatches
actions to the correct component.
|
Parallel composition is very similar to the previous example. The duplicate
boilerplate to set up state is a bit unfortunate though.
|
Fun fact: Bonsai got its name from parallel-composition! If you visualize
the structure of components that are composed in parallel, it looks like a
little tree; hence the name "Bonsai!" Sadly it wasn't until after 1.0 that
we realized that its real power was sequential composition...
03 - Sequential Composition
Components are composed sequentially when there's a dependency relationship
between them. They are no longer independent, and the state of one component
can influence the other.
In this demo, we'll use the counter value of one component to modify the
delta parameter on the other.
| Bonsai 2023 |
Bonsai |
Elm |
React |
open! Core
open! Import
let app graph =
let first_view, by = Counter.component ~label:(Bonsai.return "first") graph in
let second_view, _ = Counter.component ~label:(Bonsai.return "second") ~by graph in
let%map first = first_view
and second = second_view in
N.div [ first; second ]
;;
let () = Start.start app
|
open! Core
open! Import
let app =
let%sub first_view, by = Counter.component ~label:(Value.return "first") () in
let%sub second_view, _ = Counter.component ~label:(Value.return "second") ~by () in
let%arr first = first_view
and second = second_view in
N.div [ first; second ]
;;
let () = Start.start app
|
module Main exposing (main)
import Browser
import Counter
import Html exposing (Html, div)
type alias Model =
{ first : Counter.Model, second : Counter.Model }
init : Model
init =
{ first = Counter.init, second = Counter.init }
type Msg
= First Counter.Msg
| Second Counter.Msg
update : Msg -> Model -> Model
update msg model =
case msg of
First msg_first ->
{ model | first = Counter.update 1 msg_first model.first }
Second msg_second ->
{ model | second = Counter.update model.first msg_second model.second }
view : Model -> Html Msg
view model =
div []
[ Counter.view 1 "first" model.first |> Html.map First
, Counter.view model.first "second" model.second |> Html.map Second
]
main =
Browser.sandbox { init = init, update = update, view = view }
|
import React, { useReducer } from 'react';
import ReactDOM from 'react-dom';
import Counter, {
applyAction as counterApplyAction,
defaultState as counterDefaultState,
} from '../../shared/Counter';
const defaultState = {
first: counterDefaultState,
second: counterDefaultState,
};
function applyAction(state, { which, subAction }) {
switch (which) {
case 'first':
return {
...state,
first: counterApplyAction(state.first, subAction, 1),
};
case 'second':
return {
...state,
second: counterApplyAction(state.second, subAction, state.first),
};
}
}
const App = () => {
let [state, inject] = useReducer(applyAction, defaultState);
let injectFirst = (subAction) => inject({ which: 'first', subAction });
let injectSecond = (subAction) => inject({ which: 'second', subAction });
return (
<div>
<Counter label="first" by={1} state={state.first} inject={injectFirst} />
<Counter
label="second"
by={state.first}
state={state.second}
inject={injectSecond}
/>
</div>
);
};
ReactDOM.render(<App />, document.getElementById('app'));
|
|
Same differences as before for the most part.
|
We finally get to use the extra return value from Counter.component! We
bind the value, and immediately pass it into the next component through its
optional parameter. The rest of the code should be very familiar.
|
On the Elm side, the code looks very similar to the code from the "parallel composition"
example above, but the differences matter a lot! The main change is that calling the
second counter-component's update and view functions, instead of passing in 1 for
"how much to increase or decrease the value by", we reach in to the model of the first
component to pull out the currently stored value. I'll be honest, this makes me feel
a bit icky; I'd love to know if there's a better way to do this.
|
Sequential composition for React starts looking a lot more like the Elm example. It
would be reasonable to look at this code and ask the question "why are you building
a big reducer instead of applying two smaller reducers?" The reason is that with separate
reducers, transformations applied at the same time will not be able to witness one
another. In many scenarios, this kind of race condition is important to handle manually,
and the only way to do so is by putting all the actions inside the same reducer.
|
04 - Multiplicity
So far we've dealt with a constant number of components, but determining the number of
components in an app at runtime is a common requirement. For this example, we'll use the
current value of one counter component to determine how many more counter-components
should be on the page.
An important additional restriction is that subcomponent state should be persisted for
compoenents even when they aren't currently active.
| Bonsai 2023 |
Bonsai |
Elm |
React |
open! Core
open! Import
let app graph =
let counter_view, how_many = Counter.component ~label:(Bonsai.return "how many") graph in
let map =
let%map how_many in
List.init how_many ~f:(fun i -> i, ()) |> Int.Map.of_alist_exn
in
let others = Bonsai.assoc (module Int) map graph ~f:(fun key _data graph ->
let view, _ = Counter.component ~label:(key >>| Int.to_string) graph in
view)
in
let%map counter_view and others in
N.div (counter_view :: Map.data others)
;;
let () = Start.start app
|
open! Core
open! Import
let app =
let%sub counter_view, how_many =
Counter.component ~label:(Value.return "how many") ()
in
let%sub map =
let%arr how_many = how_many in
List.init how_many ~f:(fun i -> i, ()) |> Int.Map.of_alist_exn
in
let%sub others =
Bonsai.assoc
(module Int)
map
~f:(fun key _data ->
let%sub label =
let%arr key = key in
Int.to_string key
in
let%sub view, _ = Counter.component ~label () in
return view)
in
let%arr counter_view = counter_view
and others = others in
N.div (counter_view :: Map.data others)
;;
let () = Start.start app
|
module Main exposing (main)
import Browser
import Counter
import Dict exposing (Dict)
import Html exposing (Html, div, map)
type alias Model =
{ howMany : Counter.Model, others : Dict Int Counter.Model }
init : Model
init =
{ howMany = Counter.init, others = Dict.empty }
type Msg
= HowMany Counter.Msg
| ForKey Int Counter.Msg
updateOther which msg =
Dict.update which
(\m ->
Just (Counter.update 1 msg (Maybe.withDefault 0 m))
)
update : Msg -> Model -> Model
update appMsg model =
case appMsg of
HowMany msgHowMany ->
{ model | howMany = Counter.update 1 msgHowMany model.howMany }
ForKey which msg ->
{ model | others = updateOther which msg model.others }
viewOther : Dict Int Counter.Model -> Int -> Html Counter.Msg
viewOther models key =
case Dict.get key models of
Just model ->
Counter.view 1 (String.fromInt key) model
Nothing ->
Counter.view 1 (String.fromInt key) Counter.init
view : Model -> Html Msg
view model =
List.range 0 (model.howMany - 1)
|> List.map (\i -> Html.map (ForKey i) (viewOther model.others i))
|> List.append [ Html.map HowMany (Counter.view 1 "how many" model.howMany) ]
|> div []
main =
Browser.sandbox { init = init, update = update, view = view }
|
import React, { useReducer } from 'react';
import ReactDOM from 'react-dom';
import Counter, {
applyAction as counterApplyAction,
defaultState as counterDefaultState,
} from '../../shared/Counter';
const defaultState = {};
function applyAction(state, { which, subAction }) {
return {
...state,
[which]: counterApplyAction(state[which] || 0, subAction, 1),
};
}
const App = () => {
let [howMany, injectHowMany] = useReducer(
(state, action) => counterApplyAction(state, action, 1),
counterDefaultState
);
let [subcomponentState, subcomponentInject] = useReducer(applyAction, defaultState);
let subcomponents = Array.from({ length: howMany }, function (_, i) {
let injectMe = (subAction) => subcomponentInject({ which: i, subAction });
return (
<Counter
key={i}
label={i}
by={1}
state={subcomponentState[i] || 0}
inject={injectMe}
/>
);
});
return (
<div>
<Counter label="how many" by={1} state={howMany} inject={injectHowMany} />
{subcomponents}
</div>
);
};
ReactDOM.render(<App />, document.getElementById('app'));
|
|
Now that you know what to look for, nothing should surprise you here! One
pretty big space saver is that thanks to the map operation no longer being
a footgun, you can use infix-map in places that you'd previously need to
use a let%sub / let%arr combo.
|
For Bonsai, this one is a bit hacky, I'll admit! Bonsai.assoc reads an input
map and creates an instance of the provisded component for each key/value pair
in the map. Because it needs that input map, we first make a map from int to
unit, and pass that into assoc. Usually assoc is given a map that actually
has some meaning - like rows in a table - and aren't built at the last second just
to give to the function.
|
I'll admit, I know this code could be written better, but I don't really know where
to start. One thing is certain though; the pattern of storing models in a map
and keeping the model map separate from the "what is visible" state is necessary,
so I think this general pattern will always exist.
|
For this one, the subcomponent state and the "how many" state are actually independent,
so we can use two useReducer calls again! One of these reducers is for the bag of
states that are necessary for rendering the dynamic components.
|