Skip to content
Melact

templates with holes, compiled

Your reason‑react code, without React.

Change two lines in your dune files. Melact keeps reason-react's surface and compiles your JSX into DOM templates with holes. A re-render writes only the holes whose values changed.

This is the real Melact build. Each click writes only what changed.

Click + to see what it writes.

lines to change in your dune files
2
libraries and preprocess
npm runtime dependencies
0
the runtime is compiled OCaml
steps of random walks through reason-seoul's TodoMVC with the same DOM as React 18
605/605
only its dune file changed
steps of reason-react's own demo with the same DOM as React 18
37/37
compared after every click

Migrate in two lines

Change the libraries. Keep the components.

Melact keeps reason-react's surface (React.useState, useEffect, useContext, ReactDOM.Client, React.Event, ReasonReactRouter …) and replaces what is underneath it.

1your dune file

dune
- (libraries reason-react)
+ (libraries melact)
- (preprocess (pps reason-react-ppx))
+ (preprocess (pps melact-ppx))

2your components stay as they are

counter.mlx
let[@react.component] make ~initial () =
  let count, setCount = React.useState (fun () -> initial) in
  let tone = if count < 0 then "negative" else "" in
  <div className="counter">
    <button onClick=(fun _ -> setCount (fun n -> n - 1))>(React.string "-")</button>
    <output className=tone>(React.int count)</output>
    <button onClick=(fun _ -> setCount (fun n -> n + 1))>(React.string "+")</button>
    <button onClick=(fun _ -> setCount (fun _ -> initial)) disabled=(count = initial)>
      (React.string "reset")
    </button>
  </div>

In one picture

Your code stays. What runs underneath changes.

Melact replaces what is underneath reason-react's surface. Nothing in your components has to know.
Melact in one picture, for code already on React: a reason-react component switches two dune lines; Melact compiles its JSX into a template parsed once and a refresh; in the browser setState, kept for React code, re-renders only that component, checks n against its last value with one === and writes the text node. Gone: React, react-dom, the virtual DOM, a tree per render, the tree diff, fiber. Stays: your components, hooks, mlx, Reason and OCaml, ReactDOM, React.Event. Starting fresh? Write Meltea

Compatibility

Checked against React 18, not assumed.

The same tests run twice, once on reason-react with real React and once on Melact, and must see the same page.

dual test suite

One suite, two Reacts

One suite in mlx is built twice, against reason-react with React 18 and against Melact, with identical assertions.

compat/run.sh

Real apps, two lines changed

Clones reason-react, melange-opam-template and reason-seoul's TodoMVC, changes two lines in their dune files and builds them against Melact, then compares the DOM with React's after every step.

laws

Properties, not scenarios

144 vitest + jsdom tests and 25 native QCheck laws. One law sends random mouse and keyboard events that handlers prevent or stop, and React 18 and Melact must read the same values.

reason-react surface that works today

  • useState
  • useReducer
  • useEffect*
  • useLayoutEffect*
  • useMemo*
  • useCallback*
  • useRef
  • useImperativeHandle*
  • useId
  • useContext
  • React.memo
  • React.act
  • React.Event
  • React.createElement + makeProps
  • ReactDOM.Client
  • ReactDOM.Style
  • ReactDOM.Ref
  • ReactDOMServer.renderToString
  • ReactDOM.Client.hydrateRoot
  • ReasonReactRouter
What melact-ppx and the Melact library make of unchanged reason-react code: a JSX component is a direct call to make; React.createElement with makeProps is the same call with a key; React.Event accessors read the native event, delegated to document; each hook call gets a typed slot key; a component without hooks is a pure view with no instance; ReactDOM.Client clones templates into the element; renderToString and hydrateRoot are Served.render and Served.hydrate; ReasonReactRouter.useUrl reads the location on popstate
Each line of reason-react code on the left compiles, unchanged, to what is on the right.

Why Melact

The same component model, compiled.

Server rendering

HTML in React's format, hydrated by compiled code.

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, and clicks and typing made before the code arrives are delivered in order.
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

Two lines in dune. Try it on your app.

npm React components will not work; OCaml reason-react code is the target.