Skip to content

Repository files navigation

reativa

Reactive user interfaces for the web in OCaml and ReasonML, powered by fine-grained signals.

Documentation · Examples

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>

Install

opam pin add reativa https://github.com/brnrdog/reativa.git

Then 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.

Concepts

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.

Signal

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)

Computed

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)

Effect

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)

View

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.get outside a fun) becomes a tracked dynamic value that updates in place.
  • An explicit thunk (fun () -> ...) stays dynamic.
  • Signal.peek is untracked, so peek-only expressions stay static.
  • 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))) ]

Components

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>
end

Add 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.

Router

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.

Backends

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.git

Note 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.

Development

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.

License

MIT © Bernardo Gurgel

About

Build user interfaces with OCaml and ReasonML

Topics

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages