I've been thinking recently about how to structure dynamic web applications, and in particular about the role that incremental computation should play.

In this post, I'd like to outline an approach we've been experimenting with internally which uses Incremental, a general purpose library for building so-called self adjusting computations. Self adjusting computations are essentially graph-structured computations that can be updated efficiently when their inputs change.

I'll describe this all in terms of OCaml, which is the language we're doing these experiments in (courtesy of the excellent js_of_ocaml), but the ideas should be applicable to other languages.

Elm in OCaml

Let's start by outlining a simple, non-incremental approach, inspired by the Elm Architecture. In that architecture, there are three primary elements from which an application is composed: the model, the view and a set of actions.

The model represents the logical state of the application. This includes things like the data being displayed on the page, the current page being displayed, and the part of the page that's in focus. But it omits most presentation details, and doesn't specify the concrete choice of dom elements used.

Actions are the things that can happen to the model. The arrival of new data to be displayed, or the click of a mouse, are translated into actions which are in turn interpreted to update the state. These are important, but don't affect the incrementality story much, so we won't discuss them in detail.

The view is a function that takes the current model and produces a vdom (virtual dom) tree, an immutable datastructure that represents the intended state of the dom. It is the view function that makes the concrete presentation choices that are left unsaid by the model. The view determines some of the dynamic behavior of the application as well, since within the vdom you can register callbacks on keypresses or mouse clicks that enqueue actions to later be applied to the state.

We can wrap this up into a module signature, as follows.

  1. module type App_intf = sig
  2. module Model : sig
  3. type t
  4. end
  6. module Action : sig
  7. type t
  8. val apply : t -> Model.t -> Model.t
  9. end
  11. val view : Model.t -> (Action.t -> unit) -> Vdom.t
  12. end

Note that the second argument to view is a function that can be used for scheduling actions to occur. This function is used to build callbacks which are attached to the vdom.

We can combine this with a simple interface for starting up an application:

  1. val start_app
  2. : (module App_intf with type Model.t = 'model)
  3. -> init:'model
  4. -> unit

which is responsible for running the display loop.

This isn't quite enough; in particular, I've omitted the necessary hooks for kicking off asynchronous processes for doing things like grabbing data from a server. But the model above is a pretty good start, and gives you a sense of the structure of an Elm-style application.

This approach isn't too bad from a performance perspective. In particular, the on_startup function minimizes the amount of churn to the dom by, on every action, diffing the newly generated vdom against the previous instantiation, to produce a minimal patch to apply to the dom proper. And modifications to the dom itself are quite expensive.

But this approach doesn't scale to big, complex UIs. That's because, even though most actions change the model in only small ways, the full vdom tree has to be created every time. If the vdom is large and complicated, this is a serious problem.

Incremental Basics

Let's see how we can use Incremental to optimize generation of the vdom.

Imagine we want to display information about the state of a set of machines in our datacenter. For each machine, assume we have two pieces of data: the temperature, and whether that particular server is currently selected. Now, given incremental values representing the data for just one machine, how can we use Incremental to produce the vdom?

Incremental provides operators for building computations on top of incremental values. For example, the function Incr.map2 has this signature.

  1. val Incr.map2 : 'a Incr.t -> 'b Incr.t -> f:('a -> 'b -> 'c) -> 'c Incr.t

This lets us build a computation that takes two incremental inputs, and combines them to make a single new incremental. We can use this for generating an incremental vdom to display our machine.

  1. let view temperature selected =
  2. Incr.map2 temperature selected
  3. ~f:(fun temperature selected ->
  4. Vdom.text
  5. (if selected then [Vdom.class_ "selected"] else [])
  6. (sprintf "%F degrees" temperature))

We can write this a bit more naturally using the ppx_let syntax extension. Essentially, ppx_let allows us to encode maps using ordinary let binding syntax.

  1. let view temperature selected =
  2. let%map temperature = temperature and selected = selected in
  3. Vdom.text
  4. (if selected then [Vdom.class_ "selected"] else [])
  5. (sprintf "%F degrees" temperature)

The key issue here is that the code regenerating the text node will only be rerun when necessary, i.e., when the value of either selected or temperature have changes. In a complex view with lots of incremental inputs and lots of vdom nodes built on top of them, judicious use of map allow you to recompute only the vdom that are in need of an update.

Using bind

It turns out that map isn't enough. One limitation of map is that the dependencies introduced by map are static. e.g. Incr.map2 a b ~f will produce a node that reruns f every time a or b change.

But sometimes, you want dynamic rather than static dependencies. For a trivial example, imagine that our machine view has different inputs that might be used in different situations, say, there's a mode that determines whether uptime or temperature are displayed. We could implement such a view on top of map as follows.

  1. let view temperature uptime selected mode =
  2. let%map temperature = temperature
  3. and uptime = uptime
  4. and selected = selected
  5. and mode = mode
  6. in
  7. Vdom.text
  8. (if selected then [Vdom.class_ "selected"] else [])
  9. (match mode with
  10. | Temperature -> sprintf "%F degrees" temperature
  11. | Uptime -> Time.Span.to_string uptime)

Here, the appearance of the dom node is dynamic, but the dependencies are not. Even if you're in Temperature node, you'll still recompute the Vdom when the uptime changes.

On this small scale, the extra cost is trivial. But as you consider larger more complicated views, the ability to control dependencies precisely can have real value.

In this case, we can control the dependencies using bind. Here's the signature of bind:

  1. val bind : 'a Incr.t -> ('a -> 'b Incr.t) -> 'b Incr.t

The signature is deceptively simple, but it lets you do something powerful. In particular, Incr.bind i f returns an incremental whose dependencies, and even whose interior nodes, are chosen dynamically by f.

Here's a simple rewrite of the code above that takes advantage of bind.

  1. let view temperature uptime selected mode =
  2. let text =
  3. Incr.bind mode (function
  4. | Temperature ->
  5. let%map x = temperature in sprintf "%F degrees" x
  6. | Uptime ->
  7. let%map x = uptime in Time.Span.to_string x
  8. )
  9. in
  10. let%map text = text and selected = selected in
  11. Vdom.text
  12. (if selected then [Vdom.class_ "selected"] else [])
  13. text

Here, bind lets us create a text incremental that depends either on the temperature or on the humidity, depending on the mode. We can write this with our syntax extension, using its specialized match syntax.

  1. let view temperature uptime selected mode =
  2. let text =
  3. match%bind mode with
  4. | Temperature ->
  5. let%map x = temperature in sprintf "%F degrees" x
  6. | Uptime ->
  7. let%map x = uptime in Time.Span.to_string x
  8. in
  9. let%map text = text and selected = selected in
  10. Vdom.text
  11. (if selected then [Vdom.class_ "selected"] else [])
  12. text

One thing that's nice about ppx_let is that it makes it easier to separate thinking about what your code does from how it's incrementalized. If you ignore the %map and %bind annotations, what you're left with is enough to understand the meaning of the computation that's being incrementalized. The annotations are only important for understanding the performance characteristics of the incremental recomputation.

Decomposing incrementals

Here's an updated version of our App_intf which includes Incremental.

  1. module type App_intf = sig
  2. module Model : sig
  3. type t
  4. end
  6. module Action : sig
  7. type t
  8. val apply : t -> Model.t -> Model.t
  9. end
  11. val view : Model.t Incr.t -> (Action.t -> unit) -> Vdom.t Incr.t
  12. end

The only change is the view function, which instead of taking a Model.t and returning Vdom.t now takes a Model.t Incr.t and returns a Vdom.t Incr.t. And instead of calling this function on every update, we simply call it once at the beginning. The start_app function would be responsible for repeatedly updating the Model.t Incr.t as the model changes, and can then read off the new vdom node from the Vdom.t Incr.t.

This all sounds good on the surface, but there's a fly in this ointment, which is that my earlier examples were built on the assumption that the different inputs to the computation were already broken down into separate incremental values. But here, we have one big incremental value containing the entire model. How do we apply Incremental effectively in this case?

Let's revist our server-display example from before. Now, instead of assuming we have a different incremental for each property of a server, imagine we have one incremental representing the full state of one server. We can use the following as our model type:

  1. module Model = struct
  2. type display_mode = Temperature | Uptime
  3. type t = { temperature: float;
  4. uptime: Time.Span.t;
  5. selected: bool;
  6. mode: display_mode;
  7. }
  8. [@@deriving fields]
  9. end

The deriving annotation above provides us with accessor functions for each field, which will be useful shortly.

It's easy enough to write a view function that returns an incremental that recomputes in its entirety every time the model changes, as shown below.

  1. let view model =
  2. let%map model = model in
  3. let text =
  4. match model.mode with
  5. | Temperature ->
  6. sprintf "%F degrees" model.temperature
  7. | Uptime ->
  8. Time.Span.to_string model.uptime
  9. in
  10. Vdom.text
  11. (if model.selected then [Vdom.class_ "selected"] else [])
  12. text

And for this tiny example, that's likely the correct thing to do. But we'll want to incrementalize more precisely for real world examples, so let's see how we could do that in this case.

What we effectively need to do to incrementalize this is to convert our one big incremental model into a number of smaller incrementals. We can do that by projecting out individual components of the model using map.

For example, if we write:

  1. Incr.map model ~f:(fun m -> m.mode)

or, equivalently

  1. Incr.map model ~f:Model.mode

We'll get an incremental that contains only the mode. Critically, incrementals that are depend on this one will only update when the mode actually changes, not, say, the temperature is updated. That's because Incremental cuts off computations when the new output is physically equal to the old one, so that even if the model changes, each projected incremental will only propagate the computation if its data has changed.

We can use this approach to first project out the fields of the model record into different incrementals, and then build out computation on top of that. That looks like this:

  1. let view model =
  2. let mode = Incr.map model ~f:Model.mode in
  3. let temperature = Incr.map model ~f:Model.temperature in
  4. let selected = Incr.map model ~f:Model.selected in
  5. let uptime = Incr.map model ~f:Model.uptime in
  6. let text =
  7. match%bind mode with
  8. | Temperature ->
  9. let%map x = temperature in sprintf "%F degrees" x
  10. | Uptime ->
  11. let%map x = uptime in Time.Span.to_string x
  12. in
  13. let%map text = text and selected = selected in
  14. Vdom.text
  15. (if selected then [Vdom.class_ "selected"] else [])
  16. text

And this has basically the right incremental structure.

This is a good start, but we still don't really have the full story. In particular, the above approach to incrementalization makes sense when our overall data is organized as simple static structures like records. But it's not clear what to do when we have more complex data structures. For example, what if we had a collection of machines stored in a map or a set? We don't yet have a way of efficiently handling this kind of complex abstract data type using Incremental.

More on that in my next post.