Skip to content
Melact

How it works

Melact only looks like React.

The surface is reason-react's: components are functions, hooks keep state, a state change calls the component again. Underneath, the view is compiled into templates with holes, and per template a generated refresh that guards every hole by the values it is computed from. It relies on the contract React already sets, the Rules of React: rendering is a pure function of props, state and context.

01

The DOM writes are the same. The tree copy disappears.

One click in the counter, from 1 to 2. React builds a new tree of 5 elements and 5 props objects, walks it against the previous tree and writes one text. Melact keeps the markup as a template parsed once, builds one array of 6 hole values, compares each with === and writes the same one text
No virtual DOM: the DOM writes are the same, what disappears is the copy of the tree that React rebuilds and walks on every render.

02

JSX becomes a template and a hole array.

mlx JSX is compiled into a template hoisted to module level and a walk to its three holes: b is the first child of the div, o the next sibling of b, and the text goes at the end of o; the template is cloned once per mount; every render runs the compiled refresh, which checks count and tone and writes only the holes that read a changed one
  • Template. The largest static subtree of every JSX site (lowercase tags, literal attributes, literal text) becomes one HTML string, parsed once into a <template> and cloned with cloneNode(true) per mount.
  • Holes. Each dynamic attribute, handler, text or child is a hole: an element, or a parent and the node to insert before. The compiler emits the walk to them, firstChild and nextSibling only, as Svelte does; it runs on the fresh clone before anything is inserted, so nothing shifts.
  • Refresh. For every template the compiler also emits a refresh function. It checks each local the view reads with one ===, and evaluates and writes only the holes that read a changed one.

The compiler knows the shape of every template, so it writes out how to reach each hole on a fresh clone, sharing every step. The runtime keeps no paths and no tag tables.

compiled walk
View.template (Template.html "<p>a<!---->b<!----><b></b></p>") (fun scope p ->
  let n0 = Template.first p in
  let n1 = Template.next n0 in
  ...
  (View.text_hole { parent = p; before = Template.next_or_end n0 }, ...))

03

A render is one === per local.

The compiled refresh of counter-react on a click from 41 to 42: count changed, so the text and disabled holes run and the text node gets 42; tone and initial are the same, so the class hole is not even evaluated; handlers are swapped on the element without DOM calls

Nothing is built to be compared afterwards. For each hole the ppx computes its free variables, scope-aware, from the AST, and emits per template site a refresh function:

generated refresh
View.compiled template 2 (fun scope (click, label, rows_hole) ->
  let count_changed = View.check scope 0 count in          (* one === against the last count *)
  let rows_changed = View.check scope 1 rows in
  View.set_handler click dec;                              (* handlers: swapped, never guarded *)
  (match count_changed with
   | Unchanged -> ()
   | Changed -> View.set_text label (string_of_int count));     (* evaluated only now *)
  match rows_changed with
  | Unchanged -> ()
  | Changed -> View.set_content rows_hole (React.array (Array.map row rows)))

View.check scope k value compares value with the k-th remembered dependency and stores it. A hole whose free variables are all physically unchanged is neither evaluated nor compared, and a nested JSX tree inside it is not built at all: lazy everywhere, without writing it.

holewritten with
textnode.data = s
attributesetAttribute / removeAttribute (None removes)
propertyel[name] = v (value, checked, innerHTML)
stylethe old properties cleared, the new ones set
handlerguarded by the locals it reads, like any hole; Melact replaces the closure on the element
refthe new callback is called with the element
childpatched recursively: a template, a list, a component

Never guarded: an expression that reads mutable state (!r, r.contents, r.current,Js.Date, Js.Math.random, an assignment, or a variable bound by for/while) runs on every render; an expression with no free variables is constant and evaluated on mount only.

04

Keyed lists move as little as possible.

Keyed reconcile: the common prefix a and suffix f stay, c and d form the longest increasing run of old positions and stay, b and e are moved with one insertBefore each. Measured on 1,000 rows: swap 2 moves, reverse 999 moves, remove one keeps 999 rows

Reconcile.plan takes the old and new keys and returns a step per new entry: Keep, Move or Create, plus the old entries to drop. It tries the cheap cases first: common ends (a swapped first and last row is two moves), an empty side, then a small hash table from old keys to positions and the longest increasing run. When the keys keep their order the loop skips the plan and patches rows in place.

React.array (Array.map row items) compiles to a keyed loop. As in Vue Vapor's selectors, the compiler splits a row into its item and its leaves, the smallest pieces without JSX that read outer variables, for example

a leaf
match selected with Some id when id = item.id -> "danger" | _ -> ""

and emits a projection that evaluates only those. A row whose item is the same object and whose projection did not change is kept without calling the row function: selecting one row of 1,000 renders two.

A keyed row that moves is moved with parentNode.moveBefore where the browser has it, so a focused input, a playing video, an iframe or a running CSS animation inside the row survive the move; elsewhere it is insertBefore.

05

Components re-render where state changed.

A state update in TodoList re-renders TodoList only; App and Header are not visited; Rows whose props are physically equal are skipped

Each [@react.component] gets an identity at module level. Its props are compared one by one with ===. A setter marks its instance dirty, and one microtask renders the dirty instances parents first. Layout effects run right after their instance's patch. Passive effects run after the whole flush, and cleanups go children before parents.

By the Rules of React every hook is named use…, so melact-ppx sees whether a component's body can call one. A component that calls none compiles to View.pure, a child that keeps the component's identity and props and nothing else. Only components that call hooks get an instance with hook slots, effects and a place in the scheduler.

06

Hooks are typed by their call site.

melact-ppx gives every hook call site its own Type.Id key, hoisted to module level; the instance stores each slot with the key that made it; reading a slot checks provably_equal, so values come back typed, and a hook called out of order raises an error instead of returning a wrong value

Hooks keep state in slots read in call order, as in React. Instead of casting what a slot holds, melact-ppx gives every hook call site its own key, a Type.Id created once at module level, and passes it as a hidden first argument:

melact-ppx
(* you write *)
let count, setCount = React.useState (fun () -> 0)

(* melact-ppx emits *)
let __melhole_hook_2 = Melact_runtime.Hook.key ()
let count, setCount = (React.useState __melhole_hook_2) (fun () -> 0)

A slot stores its value with the key that made it, so reading it back is a typed Type.Id.provably_equal, with no Obj.magic. A hook inside an if, a match, a loop or a callback raises the hook alert at build time, reported by the OCaml type checker itself; at run time a hook called out of order raises "hooks were called in a different order" instead of handing back the wrong value.

07

Events are delegated.

One listener per event type on the document; on a click melact walks from the target up through parent nodes and calls the handler stored on each element; replacing a handler is one property write

The 22 delegated types are the bubbling set Solid uses: click, input, keydown, focusin,pointer*, mouse*, touch* and the rest. Any other event gets one listener on its element that reads the same property, so replacing its handler is free too.

A handler prop compiles to fun event -> handler (React.Event.ofNative event): ofNative is the identity, and the handler's own type picks the family. Every accessor reads the native event, and delegation sets currentTarget to the element whose handler runs, as React does.

08

Inputs show the state.

Controlled inputs: the user types milk and presses Enter in the same tick; the model's field goes from empty to milk and back to empty, so the redraw sees an unchanged value and writes nothing. A guard alone leaves milk in the field. In Meltea and Melact value and checked are controlled holes: after an input or change event and the redraw it causes, the hole writes its last value back if the DOM differs, as React does

A guard compares a value with the one written last, but the user changes value and checked too. So these two props are controlled holes: after an input or change event, and the redraw it causes, the hole writes its last value back if the DOM differs. One law types into such a field and checkbox under React 18 and under Melact and compares the page.

09

Server rendering and hydration, in React's format.

Server rendering and hydration in React's format: on the server Program.render, renderToString or server-reason-react write plain HTML with no markers, and a comment only between two adjacent texts; in the browser, code the compiler generated for each template walks the server's nodes in document order, after an off-page clone has let the template's own refresh compute the values: the root with the same tag is taken over, texts and lists take exactly their nodes, and the buttons get their handlers; no element is added to the page, and HTML that does not match the view is cleared and mounted from scratch. Before the code arrives, the capture script in the page's head records clicks, typing and key presses with each target's value, and hydration delivers them in order

ReactDOMServer.renderToString writes HTML in the format of React's own renderToString, with no markers, so a Melact app can also render on a native OCaml server with server-reason-react.ReactDOM.Client.hydrateRoot takes that HTML over in document order with code the compiler generates for each template under its-hydratable flag: it keeps the server's elements and sets handlers. HTML that does not match the view is replaced by a normal mount.

Melhole_server.capture, a script for the page's head, records what the user does before the code arrives, with each field's value at that moment, and hydration delivers it in order.

10

Mutate in place, and nothing updates.

Change detection is physical equality, exactly as in Elm. Code that mutates an array or a record in place and passes the same reference back will not re-render that part. Idiomatic OCaml builds new values, so this rarely matters, but it is a rule rather than an optimisation.