- Edited
Hi, and welcome back to my OCaml web development project! In the past few posts, we created a backend with Dream and Caqti, and added a GraphQL API. The rest of this series will focus on building a single-page application frontend with Bonsai that interfaces with the API.
Bonsai Basics
Bonsai is a frontend web development framework, doing the same job as React, Vue, or Angular, but it's written in OCaml. In the next post, I'll dive deep into Bonsai, its design principles, and why it's such a powerful tool. For now though, I'll go over how to set up Bonsai, build some basic components with it, and serve the Bonsai frontend to visitors of the site.
One of the things I found confusing before this project is the use of OCaml in a web frontend: all the web frontend programming I had done previously was in JavaScript or TypeScript, which can run directly (or with minor compilation for TS) on browsers. Turns out that Dune, OCaml's build system, supports compiling entire OCaml programs (with a few exceptions) to JavaScript using JS of OCaml. To tell Dune to compile our entire frontend to JS, we add a (modes js)
stanza to the dune
config file in our frontend
folder, which is where we'll put all the code for our frontend. The full dune
file is:
(executables
(names main)
(modes js)
(libraries
js_of_ocaml
nittany_market_frontend_graphql
bonsai
bonsai.web
bonsai.web_ui_form
ppx_typed_fields.typed_variants_lib
ppx_typed_fields.typed_fields_lib
bonsai.extra)
(preprocess
(pps ppx_jane js_of_ocaml-ppx ppx_typed_fields ppx_css))
)
Note that we declare this as an executable
, not a library
, because we want to produce a single .js
file that will run our frontend when loaded by the browser. We also include libraries and preprocessors that will be used later.
Bonsai Components Intro
Now that we've configured Dune to compile our frontend into JS, let's discuss how it's actually used. Similarly to React, Bonsai programs are organized as a bunch of nestable components, each of which represent some part of the displayed page. The full UI will be a tree of components.
In Bonsai, UI components have the type Vdom.Note.t Computation.t
. That Computation.t
part is important, because it allows us to use state and logic in our components. We'll also see in the next article that it allows us to build large, yet responsive and performant UIs. For now, let's take a look at some basic examples of components.
Hello World Component
The first component we'll cover has no logic, inputs, or state: it just displays a constant bit of HTML:
let hello_world = Bonsai.const (
Vdom.Node.div ~attr:(Vdom.Attr.class "my-class") [
Vdom.Node.p [ Vdom.Node.text "hello world" ];
Vdom.Node.span [ Vdom.Node.text "this is Bonsai" ];
])
The Vdom.Node.x
functions are a mechanism for building HTML elements in OCaml, similarly to React's createElement
function. Vdom.Attr.x
are used to specify HTML attributes on elements. Most Vdom.Node.x
take Vdom.Node.t list
as an argument, which will be rendered as that node's children. The above code maps to the following HTML:
<div class="my-class">
<p>hello world</p>
<span>this is Bonsai</span>
</div>
Note that the Vdom.Node.x
functions provide a Vdom.Node.t
, but we need a Vdom.Node.t Computation.t
. That's why we pass our vdom output to Bonsai.const
, which wraps any input in a Computation.t
.
Nested Components
Let's say we want to include some other inner component (inner
) in our hello world component. inner
has type Vdom.Node.t Computation.t
, but a Vnode can only accept Vdom.Node.t
as children. Somehow, we need to turn Vdom.Node.t Computation.t
into Vdom.Node.t
.
We actually can't extract the underlying vnode of a computation (I'll explain why in the next post). We can access it using the let%sub
and let%arr
operators, but any code that uses it will automatically be wrapped in a Computation.t
. For example,
open Bonsai.Let_syntax
(* Provides <p>I am a nested component</p>. Assume this was defined elsewhere. *)
val inner: Vdom.Node.t Computation.t
let nested =
let%sub inner = inner in
let%arr inner = inner in
Vdom.Node.div [
Vdom.Node.span [ Vdom.Node.text "hi,"];
inner;
]
val nested: Vdom.Node.t Computation.t
will output:
<div>
<span>hi,</span>
<p>I am a nested component</p>
</div>
Note that even though we didn't use Bonsai.const
, nested
is automatically wrapped in a Computation.t
because it is used after the let%sub
and let%arr
operators. In order to use these, you need to place open Bonsai.Let_syntax
at the top of your file.
You could also use this pattern to have multiple nested components:
open Bonsai.Let_syntax
let nested2 =
let%sub inner1 = inner1 in
let%sub inner2 = inner2 in
let%arr inner1 = inner1 and inner2 = inner2 in
Vdom.Node.div [
inner1;
inner2;
]
Component with State
Now, let's say we want to make a component that keeps track of some state, such as a counter. Bonsai provides a variety of functions for this, some of which I'll cover in the next article, but for now we'll use the simplest one: Bonsai.state
. It takes a state type 'model
and a default state, and returns the current state, as well as a function for setting a new state. For an example, let's build the state for our counter, which has Int.t
type, and starts at 0
:
let state = Bonsai.state [%here] (module Int) ~default_model:0
val state : (int * (int -> unit Effect.t)) Computation.t
Wait a second... we wanted the current value and the setter, and we got those, but they're wrapped up in a Computation.t
. How can we access them and use them in our component? Turns out the let%sub
and let%arr
operators aren't just for UI components, but for any 'a Computation.t
. So we can do the following:
let nested_with_state =
let counter_state = Bonsai.state [%here] (module Int) ~default_model:0 in
let%sub inner = inner in
let%sub count, set_count = counter_state in
let%arr inner = inner and count = count and set_count = set_count in
Vdom.Node.div [
Vdom.Node.p [ Vdom.Node.text (Int.to_string count) ];
Vdom.Node.button ~attr:(Vdom.Attr.onclick (fun _ev -> set_count (count + 1))) [
Vdom.Node.text "Add 1";
];
inner;
]
and get:
<div>
<p>{CURRENT_COUNT}</p>
<button>Add 1</button>
{INNER_NESTED_HTML}
</div>
When the button is clicked, the counter will increment.
Serving Bonsai
To run Bonsai, we'll add the following function to the end of our main.ml
file:
let root_component = Bonsai.const (Vdom.Node.text "hello world")
let (_ : _ Start.Handle.t) =
Start.start Start.Result_spec.just_the_view ~bind_to_element_with_id:"app" root_component
Here, "app" is the ID attribute of the HTML element in which the Bonsai UI should be mounted, and root_component
is, unsurprisingly, the root component of our UI. For now, we'll use a very simple "hello world" for our root component. Now, all we need to do is include the .js
file that will be compiled by Dune in the HTML returned by our backend:
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
crossorigin="anonymous"
/>
<script defer src="/static/main.bc.js"></script>
<title>{% block title %}{% endblock %}</title>
</head>
<body class="bg-light min-vh-100">
<div id="app"></div>
</body>
</html>
I included some standard <head/> metadata and Bootstrap CSS for styling, but otherwise this template is very minimal. The key part is the "/static/main.bc.js" script, which our Bonsai frontend will compile to, and the empty <div id="app></div>
, which is where the single page application will be mounted.
I don't think I understand Bonsai...
That's completely fine, and honestly, to be expected. Bonsai is very powerful and elegant, but also somewhat complicated to understand: I had to get my hands dirty implementing several components and ask some questions before I could grasp most of it. The next article is dedicated to exploring some of Bonsai's underlying concepts, its design philosophy, and why it is so powerful.