xote is a lightweight ReScript library that combines fine-grained reactivity and a declarative component system for building user interfaces for the web.
npm install xoteThen, 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.Propis the static-or-reactive prop module.View.Text,View.Int,View.For,View.Show,View.Attr.*,Router.location, andSSRState.signalare the building blocks used throughout these examples.
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>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.
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.
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)
View creates UI nodes. It is the official application-facing module for DOM rendering:
let className = Signal.make("card")
<div class={Prop.signal(className)}>
<View.Text> "Status: " </View.Text>
<View.Text> {className} </View.Text>
</div>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={Prop.signal(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, Prop.t values, or functions.
<View.For
each={Prop.static(["Draft", "Review", "Ship"])}
render={label => <span> <View.Text> {label} </View.Text> </span>}
/>
<ul>
<View.For
each={Prop.signal(todos)}
by={todo => todo.id}
render={todo => <li> <View.Text> {todo.title} </View.Text> </li>}
/>
</ul>
<View.Show when_={Prop.signal(isReady)} fallback={<p> <View.Text> "Loading" </View.Text> </p>}>
<p> <View.Text> "Ready" </View.Text> </p>
</View.Show>
<View.Maybe
value={Prop.signal(selectedTodo)}
fallback={<p> <View.Text> "No selection" </View.Text> </p>}
render={todo => <p> <View.Text> {todo.title} </View.Text> </p>}
/>
<View.Value
value={Prop.signal(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>Use Prop when a component prop can accept either a static value or a signal:
@jsx.component
let make = (~className: Prop.t<string>=Prop.static("badge"), ~children) => {
<span class={className}> {children} </span>
}
let tone = Signal.make("badge badge-info")
<Badge className={Prop.signal(tone)}>
<View.Text> "Live" </View.Text>
</Badge>Prop is the module for static-or-reactive props.
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)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.
Releases are automated with semantic-release and published to npm. See docs/RELEASING.md for the stable and beta channels and the release flow.
LGPL v3