Hi, and welcome back to my OCaml web development project! In this final post, we'll build a simple frontend router to showcase Bonsai's support for global state, and primitives for rendering one of several components.
match%sub
Sometimes, we want to display one of several components depending on some value. We already did this in the query loader we built last time ("Loading…" vs the inner component), and we'll need to do it again for a router, which will display one of several components depending on the route.
The most direct way to do this would be using let%sub
and let%arr
on several components at once, as well as on the "deciding" value, but that has performance implications. Recall that Bonsai is incremental: a given Value.t
node will only recompute if its dependencies change. If we use this direct approach, the update process will propagate to components that are not displayed.
Let's demonstrate with an example. We'll have several component options, one of which is returned depending on a decider
. Each of them take the same data
as input:
val comp1 : 'a Value.t -> Vdom.Node.t Computation.t
val comp2 : 'a Value.t -> Vdom.Node.t Computation.t
val comp3 : 'a Value.t -> Vdom.Node.t Computation.t
val comp4 : 'a Value.t -> Vdom.Node.t Computation.t
val default_comp : 'a Value.t -> Vdom.Node.t Computation.t
let one_of_several (decider : string Value.t) (data : 'a Value.t) =
let%sub comp1 = comp1 data in
let%sub comp2 = comp2 data in
let%sub comp3 = comp3 data in
let%sub comp4 = comp4 data in
let%sub default_comp = default_comp data in
let%arr decider = decider
and comp1 = comp1
and comp2 = comp2
and comp3 = comp3
and comp4 = comp4
and default_comp = default_comp in
match decider with
| "sel_1" -> comp1
| "sel_2" -> comp2
| "sel_3" -> comp3
| "sel_4" -> comp4
| _ -> default_comp
val one_of_several : string Value.t -> 'a Value.t -> Vdom.Node.t Computation.t
This would be functionally correct, but when data
changes, all 5 component options (and their downstream computations) would be recomputed by Bonsai, even though only one can be displayed at a time. It works, but it's inefficient.
Instead, we can use Bonsai's match%sub
primitive:
let one_of_several (decider : string Value.t) (data : 'a Value.t) =
match%sub decider with
| "sel_1" -> comp1 data
| "sel_2" -> comp2 data
| "sel_3" -> comp3 data
| "sel_4" -> comp4 data
| _ -> default_comp data
In addition to being a lot simpler, Bonsai will only recompute computations in the active branch, saving massively on performance.
Routing Component Code
The routing system we'll build will have the following parts:
- Accessor functions to get the current URL
- A "link" function, which takes a new URL and produces a button. When pressed, the URL changes to the provided one without a page refresh.
- A "router" component that takes a
string Value.t -> Vdom.Node.t Computation.t
function, governing which component to render based on the current route. The router will render that component. In order to support URL navigation, it will also change the rendered component on URL changes.
All this revolves around keeping track of the URL. We can get its current value at any time using Js_of_ocaml
bindings, but that won't let us render different components on URL changes.
Instead, we'll use Bonsai's Var
module to create a URL value that can be included in computations. We'll create it in the top-level, since there can only be one URL, but Var
can also be used in local scopes.
open Bonsai_web
open Bonsai.Let_syntax
(* This is only used for `uri_atom`'s initial value. *)
let get_uri () =
let open Js_of_ocaml in
Dom_html.window##.location##.href |> Js.to_string |> Uri.of_string
let uri_atom = Bonsai.Var.create (get_uri ())
We won't expose the atom itself in the corresponding .mli
, but we do need a public API for accessing its value, since some components will need to parse out URL parameters:
(* Current path as a `string` *)
let curr_path_novalue () = uri_atom |> Bonsai.Var.get |> Uri.path
(* Current path as a `string Value.t` *)
let curr_path = Bonsai.Var.value uri_atom |> Value.map ~f:Uri.path_and_query
Users will navigate the site by clicking on links:
let set_path =
let open Js_of_ocaml in
let set new_path =
let uri =
let curr = get_uri () in
Uri.with_path curr new_path
in
let str_uri = Js.string (Uri.to_string uri) in
Dom_html.window##.history##pushState Js.null str_uri (Js.Opt.return str_uri);
Bonsai.Var.set uri_atom uri
in
Effect.of_sync_fun set
let link_vdom ?(attrs = []) ?(children = Vdom.Node.none) path =
let link_attrs =
[
Vdom.Attr.href path;
Vdom.Attr.on_click (fun e ->
Js_of_ocaml.Dom.preventDefault e;
set_path path);
]
in
Vdom.Node.a ~attr:(Vdom.Attr.many (attrs @ link_attrs)) [ children ]
set_path
is a path -> unit Ui_effect.t
. When given a new path, it will output an effect that updates both uri_atom
and the URL displayed in the browser. We use it in link_vdom
to generate a link that changes the page without a reload.
Note that link_vdom
doesn't need to be a Value.t
or Computation.t
, since none of the inputs are incremental values or computations. However, the children
argument must be a regular Vdom.Node.t
, not a Vdom.Node.t Computation.t
. To support computation children, we'll also define the following "wrapper" link component:
let link ?(attrs = []) ?(children = Bonsai.const @@ Vdom.Node.none) path =
let%sub children = children in
let%arr children = children and path = path in
link_vdom ~attrs ~children path
This is a more complete link component, taking Vdom.Node.t Computation.t
children and string Value.t
path to produce a Vdom.Node.t Computation.t
.
Now, all that's left is the router. This is actually the simplest part:
let router routes =
let uri = Bonsai.Var.value uri_atom in
let path = Value.map uri ~f:Uri.path in
routes path
Note that we're using Bonsai.Var.value
instead of get_uri
or Bonsai.Var.get
, since we want the URI to become a dependency. Beyond that, since the route map is already of the type string.t Value.t -> 'a Computation't
, we don't need any particularly complicated logic.
And that's all! We now have a basic routing and navigation system for Bonsai. For an example of usage, see the logged-in component of my project, which is the root UI for authenticated users. If you'd like to read about potential improvements to this router in real projects, check out my project's README.
By the way, this routing system is an example of the "global variable" state sharing pattern we covered in Understanding Bonsai.
Project Conclusion
In this tutorial series, we've covered:
- Building a database-powered web backend with Dream and Caqti (link).
- Integrating Dream with ocaml-graphql-server to build a standardized GraphQL API (link).
- Setting up a Bonsai frontend, and serving it from Dream (link).
- Understanding the core concepts and basic primitives of Bonsai (link).
- Consuming our GraphQL API from a Bonsai frontend (link).
- Building a simple router in Bonsai (this article).
I hope this has been useful as an introduction to full-stack web development in OCaml, and that you enjoyed reading it as much as I did writing it. I would love to hear any questions or comments.