GitHub

xote - Fine-grained reactivity for ReScript

NPM Version Bundle size Bundle size (gzip)

xote is a lightweight ReScript library that combines fine-grained reactivity and a declarative component system for building user interfaces for the web.

Getting Started

Installation

npm install xote

Then, add it to your ReScript project's rescript.json. You'll need to declare xote as a dependency and configure JSX to use Xote's transform:

{
  "dependencies": ["xote"],
  "jsx": {
    "version": 4,
    "module": "XoteJSX"
  },
  "compiler-flags": ["-open Xote"]
}

The compiler flag -open Xote is optional, it makes the Xote modules available unqualified inside your source files.

This README uses the application-facing names for public code:

  • View is the module for building and mounting DOM nodes.
  • MaybeSignal is the static-or-reactive value wrapper (it replaces the deprecated Prop module).
  • View.Text, View.Int, View.For, View.Show, View.Attr.*, Router.location, and SSRState.signal are the building blocks used throughout these examples.

Quick Example

module App = {
  @jsx.component
  let make = () => {
    // Create reactive state
    let count = Signal.make(0)
    // Create a derived state
    let doubled = Computed.make(() => Signal.get(count) * 2)
    // Logs every time count changes:
    Effect.run(() => {
      Console.log2("Count is ", Signal.get(count))
      None // Optional clean up function
    })
    // Build the UI with JSX
    <div>
      <h1> <View.Text> "Counter" </View.Text> </h1>
      <p>
        <View.Text> "Count: " </View.Text>
        <View.Int> {count} </View.Int>
      </p>
      <p>
        <View.Text> "Doubled: " </View.Text>
        <View.Int> {doubled} </View.Int>
      </p>
      <button onClick={(_evt: Dom.event) => Signal.update(count, n => n + 1)}>
        <View.Text> "Increment" </View.Text>
      </button>
    </div>
  }
}
// Mount to the DOM
View.mountById(<App />, "app")

Since in ReScript each file is its own module, you can define a reusable component by exporting a make function from that file. The file name becomes the component name: Counter.res gives you <Counter />.

The @jsx.component attribute instructs the compiler to derive a props type from the function's labeled arguments, enabling JSX usage without boilerplate.

Here's an example of a reusable component with properties:

// Greeting.res
@jsx.component
let make = (~name: string, ~greeting: string="Hello") => {
  <p>
    <View.Text> {`${greeting}, ${name}!`} </View.Text>
  </p>
}
// Usage from another file:
<Greeting name="World" /> // <p>Hello, World!</p>
<Greeting name="Universe" greeting="Hey" /> // <p>Hey, Universe!</p>

Core Concepts

Xote focuses on clarity, control, and performance. The goal is to offer precise, fine-grained updates and predictable behavior with a minimal set of abstractions, while leveraging the robust type system from ReScript.

Reactive Primitives

Xote uses rescript-signals for its reactive primitives:

  • Signal: Reactive state container - Signal.make(value)
  • Computed: Derived reactive value that updates automatically - Computed.make(() => ...)
  • Effect: Side-effect functions that re-run when dependencies change - Effect.run(() => ...)

All reactive primitives feature automatic dependency tracking. No manual subscriptions needed.

View System

On top of the reactive primitives with signals, Xote provides a declarative view system:

  • JSX Support: Build user interface using JSX in a declarative and familiar manner
  • Reactive DOM Nodes: Fine-grained reactivity that updates DOM nodes directly, no virtual DOM required
  • Built-in Router: Client-side routing with pattern matching and a reactive location state
  • Automatic Cleanup: Effect disposal and memory management built into the component lifecycle
  • Server-side Rendering: pre-render your pages on the server with full hydration (experimental)

Views and Attributes

View creates UI nodes. It is the official application-facing module for DOM rendering:

let className = Signal.make("card")
<div class={className}>
  <View.Text> "Status: " </View.Text>
  <View.Text> {className} </View.Text>
</div>

Built-in attributes take a plain value, a signal, or a unit => 'a function, so a signal can be passed straight through without wrapping it.

For rendering collections in JSX, prefer View.For. Add by when items have stable identity and should reconcile by key:

type todo = {id: string, title: string}
let todos = Signal.make([
  {id: "1", title: "Write docs"},
  {id: "2", title: "Ship release"},
])
<View.For
  each={MaybeSignal.reactive(todos)}
  by={todo => todo.id}
  render={todo => <li> <View.Text> {todo.title} </View.Text> </li>}
/>

View also provides component primitives for static or reactive values. Their children can be raw values, signals, MaybeSignal.t values, or functions.

<View.For
  each={MaybeSignal.static(["Draft", "Review", "Ship"])}
  render={label => <span> <View.Text> {label} </View.Text> </span>}
/>
<ul>
  <View.For
    each={MaybeSignal.reactive(todos)}
    by={todo => todo.id}
    render={todo => <li> <View.Text> {todo.title} </View.Text> </li>}
  />
</ul>
<View.Show when_={MaybeSignal.reactive(isReady)} fallback={<p> <View.Text> "Loading" </View.Text> </p>}>
  <p> <View.Text> "Ready" </View.Text> </p>
</View.Show>
<View.Maybe
  value={MaybeSignal.reactive(selectedTodo)}
  fallback={<p> <View.Text> "No selection" </View.Text> </p>}
  render={todo => <p> <View.Text> {todo.title} </View.Text> </p>}
/>
<View.Value
  value={MaybeSignal.reactive(count)}
  render={count =>
    <p>
      <View.Text> "Count: " </View.Text>
      <View.Int> {count} </View.Int>
    </p>
  }
/>
<p>
  <View.Text> "Count: " </View.Text>
  <View.Int> {count} </View.Int>
  <View.Text> ", ready: " </View.Text>
  <View.Bool> {isReady} </View.Bool>
</p>

Static or Reactive Values

MaybeSignal is how a value says whether it is plain or reactive. There is one rule for when you need it:

Where What it accepts
Built-in HTML/SVG attributes, View.Text/Int/Float/Bool anything — a plain value, a Signal.t, a unit => 'a function, or a MaybeSignal.t. No wrapper needed.
Props with a declared type — View.Show, View.For, View.Maybe, View.Value, and the components you write a MaybeSignal.t, so the wrapper is how the caller says which one they are passing.
@jsx.component
let make = (~className: MaybeSignal.t<string>=MaybeSignal.static("badge"), ~children) => {
  <span class={className}> {children} </span>
}
let tone = Signal.make("badge badge-info")
<Badge className={MaybeSignal.reactive(tone)}>
  <View.Text> "Live" </View.Text>
</Badge>

MaybeSignal.t<'a> is Reactive(Signal.t<'a>) | Static('a). Build one with MaybeSignal.static, MaybeSignal.reactive, or MaybeSignal.computed(fn) for a derived value. Read it with MaybeSignal.get (tracked) or MaybeSignal.peek (untracked), and transform it with MaybeSignal.map, which preserves staticness.

MaybeSignal.ofUnknown is the coercion the JSX runtime applies to untyped props; reach for it only when adapting untyped input of your own.

Deprecated: the old Prop module is a deprecated alias of MaybeSignal. Prop.t is a type alias of MaybeSignal.t, so migrating is a rename: Prop.staticMaybeSignal.static, Prop.signal and Prop.reactiveMaybeSignal.reactive, Prop.getMaybeSignal.get.

Router and SSR State

Initialize the router once at your app entry, then describe your screens with the Router.routes component. Each route matches a pattern and receives the parsed params:

Router.init(())
let app = () =>
  Router.routes([
    {pattern: "/", render: _ => <Home />},
    {pattern: "/about", render: _ => <About />},
    {
      pattern: "/users/:id",
      render: params =>
        <UserPage id={params->Dict.get("id")->Option.getOr("")} />,
    },
  ])

Use the Router.Link component for client-side navigation without a full page reload:

<nav>
  <Router.Link to="/"> <View.Text> "Home" </View.Text> </Router.Link>
  <Router.Link to="/about" class="nav-link"> <View.Text> "About" </View.Text> </Router.Link>
</nav>

For server/client state transfer, prefer SSRState.signal when creating a synced signal:

let count = SSRState.signal("count", 0, SSRState.Codec.int)

JavaScript Interop

Xote is built for ReScript first, but the compiled package can also be used from JavaScript. Import the focused client entry and build nodes with View or Html helpers:

import { Signal, Computed, Effect, View } from "xote/client";
const count = Signal.make(0);
const doubled = Computed.make(() => Signal.get(count) * 2);
Effect.run(() => {
  console.log("Count:", Signal.get(count));
});
const app = View.element("div", [], [], [
  View.element("h1", [], [], [View.text("Counter")]),
  View.element("p", [], [], [
    View.text("Count: "),
    View.signalText(() => String(Signal.get(count))),
  ]),
  View.element("p", [], [], [
    View.text("Doubled: "),
    View.signalText(() => String(Signal.get(doubled))),
  ]),
  View.element(
    "button",
    [],
    [["click", () => Signal.update(count, n => n + 1)]],
    [View.text("Increment")],
  ),
]);
View.mountById(app, "app");

Use xote/client for browser UI, xote/router for routing, xote/ssr for server rendering, xote/hydration for hydrating server-rendered pages, and xote/mdx for MDX integration.

Check the website for more comprehensive documentations about Xote and Signals.

Releasing

Releases are automated with semantic-release and published to npm. See docs/RELEASING.md for the stable and beta channels and the release flow.

License

LGPL v3

Read the original on github.com ↗