What do we lose moving from Reason to mlx?

What Reason gives us beyond “just JSX”

SEP 2026

•

6 MINUTES

•

DAVESNX

Reason is technically a different syntax for writing OCaml programs. You can mix and match Reason and OCaml however you want in any project.

We often say that “Reason sits on top of OCaml”, so all the tooling just works: the compiler, build system, package manager, LSP, and documentation are exactly the same.

Besides this, I see Reason as a bit more than “just a different syntax” and more like an umbrella of syntax + style + accessibility to functional programming. One of the goals was to move OCaml much closer to the web.

mlx, on the other hand, is a very minimal extension to the OCaml syntax that adds just JSX, the possibility to express HTML-like elements as a first-class citizen in the language.

A component looks like this

let[@react.component] make ~name ~user =
  <section className="profile">
    <h2>(React.string name)</h2>
    <p>
      (React.string
         (match user with
         | Online -> "Online"
         | Offline -> "Offline"))
    </p>
  </section>

Does syntax matter?

People say that syntax doesn't matter, and they are wrong.

I consider myself a creative person. I appreciate beauty and great user experiences, and I often solve complex problems by drawing or creating clearer visualizations.

Syntax is the most important touch point of the interface of the language, followed by error messages. And as such, it needs to be treated as crucial.

There's some truth in the opposite view, where the meaning is the substance and at the end of the day, it will remain the only factor. Mega true, but in cases where the syntax stops you from getting the meaning... it stops being a detail.

Does syntax matter for LLMs?

Not much. Models were better at Python and JavaScript because they had seen more of them. Today a fast compiler or a fast linter with clear errors does more for a model than a familiar syntax: it writes, reads the error, and fixes it in seconds. Reason and mlx share the compiler and the errors, so nothing changes here.

Here's the list

Anyway, here's the list of things we lose:

JavaScript objects

Reason lets us write JavaScript objects with quoted keys. The keys can contain punctuation or words that OCaml reserves.

let headers = {
  "user-agent": userAgent,
  "Content-Type": contentType,
  "type": requestType,
};

In the converted code, the object uses [%mel.obj]. Each renamed field needs [@mel.as] to keep its JavaScript key. Since record fields are idents, and can't express all the JavaScript object fields.

let headers =
  [%mel.obj
    {
      user_agent = userAgent [@mel.as "user-agent"];
      contentType = contentType [@mel.as "Content-Type"];
      type_ = requestType [@mel.as "type"];
    }]

Uncurried functions and calls

The curried form takes one argument and returns a function for the next

let add = left => right => left + right;
let total = add(1, 2);

In Reason, a dot inside the argument list marks an uncurried function or call.

let add = (. left, right) => left + right;
let total = add(. 1, 2);

In mlx with Melange, the same operations use attributes

let add = (fun left right -> left + right) [@u]
let total = (add 1 2 [@u])

We lose the dedicated notation: the dot beside the arguments becomes an attribute after the expression.

Attribute placement

Reason puts a deriving attribute before its type declaration

[@deriving (enumerate, toString)]
type hAlign =
  | Left
  | Center
  | Right;

In mlx, the attribute follows the declaration

type hAlign =
  | Left
  | Center
  | Right
[@@deriving enumerate, toString]

The same happens with Melange bindings

[@mel.get]
external tagName: Dom.element => string = "tagName";
external tagName : Dom.element -> string = "tagName"
[@@mel.get]

This does not apply to every attribute or ppx extension. Forms such as let[@react.component], let%browser_only, and match%platform can still put the annotation near the start.

Precedence

In Reason, the parentheses around a function's arguments make the call boundary explicit

let value = React.Event.Form.target(event)##value;

OCaml uses spaces for function application. To read a property from the result, we put parentheses around the call

let value = (React.Event.Form.target event)##value

Without those parentheses

React.Event.Form.target event##value

the expression groups as

React.Event.Form.target (event##value)

This is a different operation: it reads value from the event first, then passes it to target.

Data-first pipe

In Reason we have ->, called data-first pipe, it applies the value to the function, as a first argument on the function

value->f(arg);
/* Equivalent to f(value, arg). */

With Melange, the data-first pipe is written |.

value |. f arg

|> remains unchanged.

Descriptive binding operators

Reason lets binding operators have descriptive names. For example, promise operators

let view = id => {
  let.await user = fetchUser(id)
  and.await settings = fetchSettings(id);
  render(user, settings);
};

OCaml has binding operators too, but only with symbolic names such as let* and and+. There's no descriptive form in mlx.

Spread operator

Reason uses the spread operator ... in three places: lists, records, and JSX children.

let all = [first, ...rest];
let next = {...person, age: person.age + 1};

OCaml have its own notation ::. It puts an element in front of a list, and with copies a record with some fields changed. Patterns follow the same rule: [head, ...tail] becomes head :: tail.

let all = first :: rest
let next = { person with age = person.age + 1 }

JSX children spread doesn't have an equivalent. It passes an existing value as the children, without wrapping it in a new list

<Layout> ...children </Layout>

In mlx, children always become a list. The closest form is to pass children as a prop

<Layout children />

The list of Reason features is long, but there are always tradeoffs, what are the good bits of mlx?

mlx is awesome, actually

It's an extension of OCaml that generates OCaml code, like .mly (Menhir) or .mll (ocamllex). Its JSX syntax could even be proposed upstream.

Less to maintain

mlx wins in maintenance. It is OCaml's parser plus the JSX rules, and ocamlformat-mlx is ocamlformat plus a small patch. For Reason, each OCaml release means adapting Reason to the changes.

Clearer story

Melange, Reason, ReScript, BuckleScript, js_of_ocaml: people who are not familiar with these projects struggle to understand the differences, and I don't blame them. The ecosystem is modular, which may be great for experts, but alien for newcomers. With mlx, you just write OCaml (with a bit of JSX).

Community effort

This fits the wider work on dune and Melange (with single context), and my work on transforming the ecosystem into universal: libraries to be easy to compile to both JavaScript and native executables. Today we rely on copy_files and custom directories for that. The single-context work in Dune is part of making this simpler. Reason benefits from that work too, ofc, but mlx gives us a clearer way to describe the language we are using.

What about Reason, then?

Reason maintains a substantially different syntax and its own formatter while staying compatible with OCaml's Parsetree. mlx also targets that AST, but stays much closer to OCaml's grammar. Reason has accumulated technical debt, including formatting bugs. Most of those are fixable, and I think LLMs could help. We could support more features by encoding them with Reason-specific attributes in OCaml's Parsetree, but that breaks the abstraction and adds more machinery to maintain.

It has been in maintenance mode for a few years, and the momentum is long gone. The Discord has been quiet for a long time. It's sad to say that about a project I helped maintain and believed in for such a long time. But maybe mlx is the next chapter we should work on, a new beginning, or at least a fresh start. What do you think?

Thanks for reading!
Any feedback is appreciated.

@davesnx