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:
Viewis the module for building and mounting DOM nodes.MaybeSignalis the static-or-reactive value wrapper (it replaces the deprecatedPropmodule).View.Text,View.Int,View.For,View.Show,View.Attr.*,Router.location, andSSRState.signalare 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
Propmodule is a deprecated alias ofMaybeSignal.Prop.tis a type alias ofMaybeSignal.t, so migrating is a rename:Prop.static→MaybeSignal.static,Prop.signalandProp.reactive→MaybeSignal.reactive,Prop.get→MaybeSignal.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