Reactive user interfaces for the web in OCaml and ReasonML, powered by fine-grained signals.
Views mount real DOM nodes once, then update only the text, attributes and regions that read a changed signal. There is no virtual DOM and no diffing. The same code compiles to JavaScript through Melange or js_of_ocaml.
open Reativa
open Reativa.View.Mlx
let count = Signal.make 0
let doubled = Computed.make (fun () -> Signal.get count * 2)
let () =
View.mount_by_id "app"
<section>
<p>(Signal.get doubled)</p>
<button onClick=(fun _ -> Signal.update count (fun n -> n + 1))>
("+1")
</button>
</section>opam pin add reativa https://github.com/brnrdog/reativa.gitThen name the library in dune, and enable the mlx dialect in
dune-project if you want JSX-like markup:
; dune-project
(using melange 0.1)
(dialect
(name mlx)
(implementation
(extension mlx)
(merlin_reader mlx)
(preprocess (run mlx-pp %{input-file})))); dune
(melange.emit
(target output)
(modules app)
(libraries reativa)
(preprocess (pps reativa.mlx_ppx melange.ppx)))The Get started section of the docs walks through the same steps in OCaml and in ReasonML.
Five modules live under the Reativa namespace. The signal graph is plain
OCaml — it runs and tests natively — and only the View and Router layers
touch the browser. The full reference is on the
docs site.
Mutable reactive state. get tracks a dependency, peek reads without
tracking, and batch groups writes into a single flush.
let count = Signal.make 0
let () =
Signal.set count 1;
Signal.update count (fun n -> n + 1);
Signal.batch (fun () -> Signal.set count 0)Lazy derived state. It tracks every Signal.get made while computing and
refreshes when one of those dependencies changes. Pass ~equals to stop
downstream work when the derived value is unchanged.
let doubled = Computed.make (fun () -> Signal.get count * 2)
let parity = Computed.make ~equals:( = ) (fun () -> Signal.get count mod 2)Runs immediately, then re-runs whenever a tracked read changes. Return
Some cleanup to undo work before the next run; use run_with_disposer to
stop an effect by hand.
let () =
Effect.run (fun () ->
Printf.printf "count: %d\n" (Signal.get count);
None)Views build DOM nodes once. In .mlx files, JSX props and children infer
whether they are static or reactive, so an inline Signal.get updates that
exact node:
let counter =
<button
className=(if Signal.get count > 0 then "counter on" else "counter")
onClick=(fun _ -> Signal.update count (fun n -> n + 1))
>
("Count ") (Signal.get count)
</button>The inference rules are:
- An eager signal read (a
Signal.getoutside afun) becomes a trackeddynamicvalue that updates in place. - An explicit thunk
(fun () -> ...)staysdynamic. Signal.peekis untracked, so peek-only expressions staystatic.- Reads inside a nested
fun— an event handler, a callback — are left alone. - Anything else is
static, created once.
Bare children work for string, int, float, an already-built view, or a
thunk. Options and booleans are not in that set: under js_of_ocaml None,
false and 0 share one JavaScript representation, so use View.Maybe and
View.Show, which carry the OCaml type through and behave the same on both
backends.
<View.Show condition=(fun () -> Signal.get count > 0) fallback=(<p>("Hidden")</p>)>
<p>("Visible")</p>
</View.Show>
<View.ForEach
items=(fun () -> Signal.get todos)
key=(fun todo -> string_of_int todo.id)
render=(fun todo -> <li>(todo.title)</li>)
/>When the structure of a region depends on a signal, wrap it in
View.tracked; for text and attributes, prefer inline reads, which patch the
node itself.
Constructor-style views are always available and take explicit wrappers —
View.static / View.dynamic — which is what the ppx emits:
View.button
~events:[ View.On.click (fun _ -> Signal.update count (fun n -> n + 1)) ]
[ View.text (View.dynamic (fun () -> string_of_int (Signal.get count))) ]A component is a module with a component binding. Its labelled arguments are
the props, and the module name becomes a capitalized tag:
module Greeting = struct
let component = fun ~name ->
<h2>("Hello, ") (name) ("!")</h2>
end
let view = <Greeting name="OCaml" />Props are ordinary OCaml values — records, signals, functions — and are type-checked at the call site, so a missing or misspelled prop is a compile error. A prop whose name matches the variable can be punned:
module TodoItem = struct
let component = fun ~todo ->
<li className="todo">
<input type_="checkbox" checked=todo.completed onChange=(toggle todo.id) />
<span>(todo.title)</span>
</li>
end
module TodoList = struct
let component = fun ~todos ->
<ul>
<View.ForEach
items=(fun () -> Signal.get todos)
key=(fun todo -> string_of_int todo.id)
render=(fun todo -> <TodoItem todo />) (* punned: ~todo:todo *)
/>
</ul>
endAdd a ~children argument to accept nested markup. It arrives as a
View.t list, which View.fragment splices into place:
module Card = struct
let component = fun ~title ~children ->
<section className="card">
<h3>(title)</h3>
(View.fragment children)
</section>
end
let view =
<Card title="Tasks">
<TodoList todos=filtered_todos />
</Card>Components are plain functions, so they can also be applied directly —
Greeting.component ~name:"OCaml" — and they carry no lifecycle of their own:
a component body runs once, when the view is built, and signals do the
updating from there.
Client-side routing: a reactive location signal, pushState / replaceState
wrappers, link interception, redirects and route matching.
<main>
<nav>
<Link href="/">("Home")</Link>
<Link href="/users/42">("Ada")</Link>
</nav>
<Router>
<Route path="/"><h1>("Home")</h1></Route>
<Route
path="/users/:id"
render=(fun matched ->
<h1>("User " ^ Option.value ~default:"" (Router.param matched "id"))</h1>)
/>
<Route path="/old"><Redirect to_="/" /></Route>
</Router>
</main>Router.navigate moves programmatically and accepts optional history state;
Router.location () exposes the current location as a signal. The
constructor-style equivalents are Router.route, Router.outlet,
Router.link and Router.redirect.
The reactive core, View, Router and the mlx ppx are plain OCaml. Only the
browser FFI is backend-specific: it sits behind two dune virtual modules,
Dom and History, so picking a backend is a line in dune.
| Backend | Select with | Output |
|---|---|---|
| Melange (default) | (libraries reativa) |
per-module ES modules, tree-shakeable; bundle with esbuild or Vite |
| js_of_ocaml | (libraries reativa reativa-jsoo) |
one self-contained script, no bundler, full opam ecosystem |
Melange is the default because of output size: it ships no OCaml runtime, so a bundler can tree-shake what is left. js_of_ocaml links the runtime and every reachable module into a single script — a fixed cost that matters less as an app grows, and the right trade for an app that is already js_of_ocaml or needs opam packages Melange cannot consume. CI measures both bundles on every run and prints the numbers to the job summary.
The js_of_ocaml backend ships as a second package, pinned from the same repo:
opam pin add reativa-jsoo https://github.com/brnrdog/reativa.gitNote that reativa-jsoo still depends on reativa, which carries the Melange
backend — dune requires the core to be built in Melange mode for the default
implementation to link against it.
See examples/jsoo/ for the same demos built both ways.
opam install . --deps-only --with-test
npm install| Command | What it does |
|---|---|
opam exec -- dune test |
native test suite (signals, router, ppx) |
npm run demo + npm run demo:serve |
build and serve the Melange todo demo |
npm run demo:watch |
rebuild the demo on change (pair with demo:serve) |
npm run demo:jsoo |
build the js_of_ocaml demos — see examples/jsoo/ |
npm run docs:dev |
run the documentation site locally |
npm run playground |
write a component and compile it from the browser (local only) |
The docs site and the playground live in website/, which has its
own README.
Commits follow Conventional Commits — commitlint enforces it on commit, and releases are cut by semantic-release.
MIT © Bernardo Gurgel