Skip to content
Melact

Migrating from reason-react

Change two lines. Keep everything else.

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

1

Switch two lines in dune

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

Your components stay as they are: the same JSX in mlx, Reason or OCaml [@JSX], and the same hooks with the same signatures.

2

What works

Runtime

  • View
  • Template
  • Reconcile
  • delegated events

Hooks

  • useState
  • useReducer
  • useEffect*
  • useLayoutEffect*
  • useMemo*
  • useCallback*
  • useRef
  • useImperativeHandle*
  • useId

React

  • components
  • context
  • React.memo
  • React.act
  • React.Event (every family)
  • React.createElement with makeProps

ReactDOM

  • ReactDOM.Client
  • ReactDOM.Style
  • ReactDOM.Ref
  • ReactDOMServer.renderToString
  • ReactDOM.Client.hydrateRoot

Routing

  • ReasonReactRouter

JSX

  • Reason
  • OCaml [@JSX]
  • mlx

Every hook with a key is supported. Every React.Event family reads the native event; handlers see the currentTarget they were set on, as in React. Delegated mouse events skip the handlers of a disabled button, input, select or textarea and go on bubbling, as React does.

3

What changes underneath

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.

melact-ppx writes makeProps next to every [@react.component], so React.createElement Foo.make (Foo.makeProps ~label ()) is make ~label () with a key, checked by the type checker, and no props object exists at run time.

4

Rules that matter

  • Render purely. Melact relies on the Rules of React: rendering is a pure function of props, state and context. React Compiler relies on the same contract.
  • Do not mutate in place. Change detection is physical equality. Code that mutates an array or a record in place and passes the same reference back will not re-render that part.
  • Hooks at the top level. A hook inside an if, a match, a loop or a callback raises the hook alert at build time. It is a warning, not an error, so reason-react code that bends the rule still compiles.
  • A key resets. A child with a key is mounted again when the key changes, as in React, which is how React code resets a component.

5

Server rendering

ReactDOMServer.renderToString writes HTML in React's format, so React's own and server-reason-react's HTML hydrate too, and a Melact app renders natively with server-reason-react. Hydration code is compiled under-hydratable. useId stays a client counter: Melact's hydration writes every attribute from the client render, so ids inid, htmlFor and aria-* agree on the page whatever format the server used.

6

How it is checked

37/37

steps of reason-react's own demo with the same DOM under React 18 and Melact, compared after every click.

605/605

steps of five random walks through reason-seoul's TodoMVC: clicks, typing, submits and route changes.

compat/run.sh clones reason-react, melange-opam-template and reason-seoul's TodoMVC, changes two lines in their dune files and builds them against Melact. Then it renders reason-react's demo with React and with Melact and compares the DOM after every click, and walks the TodoMVC randomly comparing the DOM after every step.

The TodoMVC was written for reason-react 0.9, so both copies first get the same upgrade to reason-react 0.17 (ReactEvent. becomesReact.Event., and Index.re uses ReactDOM.Client). One demo component binds React's npm useReducer directly, which is a non-goal, so the script removes it.

7

Not yet, and limits

  • Not yet: React.createElementVariadic, code typed against React.component('props), Suspense on the server.
  • Limits: a component or custom hook that is polymorphic in its state type gets a weak type for its hook key; annotate it, or keep the state type concrete.

8

Not goals

  • npm React components. react-query and other packages from npm will not work. Only OCaml reason-react code is the target.
  • Suspense, concurrent rendering, useTransition semantics. useTransition and startTransition run synchronously.
  • Server components.
  • Signals. There is no reactive graph. A component function runs again, and its holes are compared.