Skip to content
Meltea

Hoş geldiniz · OCaml · Melange · mlx

The Elm architecture for OCaml, compiled like Svelte.

A model, messages, a pure update, a pure view. No hooks, no components with state, no virtual DOM. The view is steeped at build time into a DOM template with holes and a refresh that writes only what changed.

The glass is blown once. Every render just pours the tea.At build time meltea-ppx turns the counter's mlx view into a template, parsed once and cloned per mount, with one hole: the output's text. At run time a click runs update, one check compares count with its last value, and one write sets the text node; there is no virtual DOM, tree diff, component tree, reconciler or scheduler.BUILD TIMEsteeped at build timelet view count =<div className="counter">…<output>(Html.int count)</output>meltea-ppx<div class="counter"><button>-</button><output>●</output><button>+</button>…static markup →a template,parsed once,cloned per mount● one hole: the text↑ BUILD↓ RUN TIMEa clickupdate → 0count !== lasttext.data = "0"No virtual DOM.One === per value, one write per change.

DOM writes to the output: 0

The glass is blown once. Every render just pours the tea.

of hand-written vanilla JS, geometric mean of nine keyed operations
×1.15
React 18: ×1.70
gzip JavaScript of the whole benchmark app
9.0 kB
React 18: 45.6 kB
gzip for the counter app, runtime and Melange stdlib included
3.0 kB
TodoMVC: 14.7 kB
DOM moves to swap two rows of 1,000
2
React moves 998

The recipe

Four pieces, all of them pure.

The Elm architecture in OCaml: a model, messages, a pure update and a pure view in mlx. No hooks and no components with state: an event handler is a message.
counter.mlx
type message = Increment | Decrement | Reset

let update message count =
  match message with Increment -> count + 1 | Decrement -> count - 1 | Reset -> 0

let view count =
  <div className="counter">
    <button onClick=Decrement>(Html.text "-")</button>
    <output>(Html.int count)</output>
    <button onClick=Increment>(Html.text "+")</button>
    <button onClick=Reset disabled=(count = 0)>(Html.text "reset")</button>
  </div>

let () = Option.iter (fun root -> ignore (Program.sandbox ~init:0 ~update ~view root)) root
  1. 1ModelcountAny OCaml value. Immutable, so an unchanged reference is an unchanged value.
  2. 2MessagesIncrement | Decrement | ResetA variant. An event handler is a message, not a closure over state.
  3. 3updateupdate message countA pure function from a message and the model to the next model.
  4. 4viewview countA pure function in mlx, compiled into a template and a refresh.
The counter in Meltea: you write the view in mlx; Meltea compiles it into a template tree of div, two buttons and an output whose text is the only hole; when + is clicked the update makes count 42, the compiled code compares count with its last value and writes 42 into that one text node; the grey static nodes get no DOM writes; there is no virtual DOM

The counter's view compiles to this JavaScript (names shortened):

counter.js
const template = View.template(Template.html("<div class=\"counter\"><button>-</button>…"),
  (scope, div) => {
    const minus = div.firstChild, output = minus.nextSibling, plus = output.nextSibling, …;
    return [handler_hole(scope, minus, "click"), text_hole({ parent: output, before: null }), …];
  });

const view = (count, dispatch) =>
  View.compiled(template, 2, (scope, holes) => {
    const sent = check(scope, 0, dispatch), counted = check(scope, 1, count); // one === each
    if (sent === Changed) set_handler(holes[0], () => dispatch(Decrement));  // first draw only
    if (counted === Changed) set_text(holes[1], String(count));
    …
  });

Steeped at build time

The compiler reads your update, so you annotate nothing.

No signals and nothing in your code. meltea-ppx finds which message writes which field, and which part of the view reads it.

Fields

The compiler reads update and view: which model fields each message can change, which fields the view reads. A message that changes nothing the view reads does not call it.

Fanout alerts

It follows each frequent event to the message it sends, the fields that message writes and the lists that read them. When typing would make a list check all its rows, it says so at the handler.

Remembered values

A derived let at the top of a view is computed again only when a field it reads changed; otherwise it is the same list, and the holes that read it are skipped.

What the alert looks like, at the handler, at build time:

dune build
File "todomvc.mlx", line 142, characters 19-44:
142 |            onInput=(fun text -> Retype text)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^
Alert fanout: each input event here sends Retype, which changes editing;
the list at line 187 reads editing, so every input event checks all of its rows
How fanout is found →
Fanout in Meltea's TodoMVC: meltea-ppx follows a frequent event to the lists it reaches. The edit field's onInput sends Retype, whose branch of update writes editing, and the list in view reads todos, filter and editing, so every keystroke makes the list check all its rows; the compiler raises the alert fanout at the handler. The new-todo field's onInput sends Type, which writes field, which no list reads, so typing writes one hole and there is no alert. A key handler's literal branch such as Enter sends Add once and does not count

Served hot

Keep the state while you edit. Render on a server without a DOM.

Hot reload

An edit shows the new view of the old model.

With ~start:Program.hot and Vite's HMR, as elm-watch does. When the module's types changed, it starts from init.

Hot reload in Meltea: a program runs on the root with its model and the shape of its module's types. After an edit, Vite runs main.mlx again, and Program.element with ~start:Program.hot finds the running program; when the new code's shape is the same, the model carries over and the new view and update show the old state; when the types changed, the program starts from init
Server rendering

One module, natively or in Node.

Under meltea-ppx -server the same view compiles to code that writes strings, with no DOM. The browser build hydrates the HTML, and clicks and typing made before the code arrives are delivered in order.

dune
(executable                              ; the server, native
 (name server)
 (libraries meltea.server)
 (preprocess (pps meltea-ppx -server)))

(melange.emit                            ; the browser
 (target app)
 (libraries meltea)
 (preprocess (pps melange.ppx meltea-ppx -hydratable)))
Transitions

Enter, leave, move. And View Transitions.

As Vue's <Transition> and <TransitionGroup>; Cmd.view_transition from an update draws inside the browser's View Transitions.

view.mlx
let view model =
  <ul className="todo-list">
    (Html.transition_group ~name:"fade" (Html.list (List.map row (visible model))))
  </ul>

Benchmarks

Within ×1.2 of hand-written JavaScript.

js-framework-benchmark's keyed operations, reproduced in headless Chrome against hand-written vanilla JS and React 18. The ratios move by about 0.1 between runs.

Time, geometric mean of nine keyed operations

Slowdown against hand-written vanilla JS

lower is better
  • vanilla JS×1.00
  • Meltea×1.15
  • React 18×1.70
› Show as a table
frameworkSlowdown against hand-written vanilla JS
vanilla JS×1.00
Meltea×1.15
React 18×1.70

JavaScript of the benchmark app

gzip size of all JS in the production bundle

smaller is better
  • vanilla JS1.4 kB
  • Meltea9.0 kB
  • React 1845.6 kB
› Show as a table
frameworkgzip size of all JS in the production bundle
vanilla JS1.4 kB
Meltea9.0 kB
React 1845.6 kB
All results and the method

Afiyet olsun

Brew your first program.

Add meltea and meltea-ppx to a melange.emit stanza, and (dialect (name mlx) …) indune-project for .mlx files. Until the opam release, pin it.

shell
opam pin add melhole meltea meltea-ppx https://github.com/jongleb/meltea.git
dune
(melange.emit
 (target app)
 (libraries meltea)
 (preprocess (pps melange.ppx meltea-ppx)))