Hello everyone,
I’ve been working on a ReScript UI library for the past year, called Xote. I had the chance to present the library earlier this year during the ReScript Retreat 2026.
Recently I released a new version, the v7.1.0. This release is a milestone on Xote’s development, with a much more stable API and improved developer experience. Which is why I feel it finally deserves a proper introduction here in the community.
If you never tried Xote before or checked the library a long time ago, now would be a good time to do. Feedback is always welcome and appreciated.
What’s Xote?
Xote is a ReScript library for building UIs around fine-grained reactivity: signals, computeds, and effects instead of a virtual DOM. Views are plain functions, JSX compiles to native calls, and updates touch only the DOM nodes that actually changed.
It also ships a router, SSR, and hydration, so you can build a full app without stitching together a pile of additional packages. The goal is a small runtime, a very solid sound type system (thanks to ReScript), and a small overhead with no unnecessary abstraction layered on top.
Here’s a small example:
module GreetingAndCounter = {
@xote.component
let make = (~name) => {
let count = Signal.make(0) // State
let duplicated = Computed.make(() => count > 2) // Derived state
Effect.run(() => {
Console.log(Signal.get(Count)) // Logs every `count` change
None // Optional cleanup
})
<div>
<h1>
{`Hello, ${name}!`}
</h1>
<p>
{`You clicked : ${Signal.get(count)}`}
</p>
<button onClick={() => Singal.update(c => c + 1)}>
{"Click"}
</button>
</div>
}
}
Xote.mountById(<GreetingAndCounter name="Xote" />, "#app")
What’s new in 7.1.0?
7.1.0 can be seen as a stabilization release after a better API consolidation from 7.0. But it also brings an important addition: @xote.component, a new PPX that changes how components get written day to day.
@xote.component
In 7.0, reactive values needed explicit primitives:
<div>
<View.Text> "Count: " </View.Text>
<View.Int> {count} </View.Int>
</div>
@xote.component reads your source and does this for you. Any {…} child in element position gets coerced at compile time: a signal read becomes a reactive leaf, a static value becomes static text, a node passes through:
@xote.component
let make = () => {
let count = Signal.make(0)
<div>
{"Count: "}
{Signal.get(count)}
</div>
}
It also derives the props type from labeled arguments (just like @jsx.component), and wraps an if/switch in child position in tracked() automatically, so conditional branches re-render as a unit without you reaching for that API by hand.
There are some limitations in the PPX though: it can’t see through a call into another module.
For example, <p>{Store.count(store)} </p> compiles fine but never updates, because the Signal reference is in another module. Rather than fail silently, we have a runtime check that catches these scenarios and the compiler will tell you exactly how to fix it:
[Xote] Queue.res:42:19: this value reads a signal through a call
@xote.component cannot see. Wrap it in a thunk (`{() => ...}`), or use the reactive primitive.
Performance
This version also introduced some performance gains, mostly around list rendering. Here’s the latest benchmarks against React, Solid and Vue:
| Xote | React | Vue | Solid | |
|---|---|---|---|---|
| Updating an existing list | ||||
| Update every 10th row (ms) | 6.3 | 11.7 | 7.4 | 5.4 |
| Select a row (ms) | 1.0 | 5.8 | 1.9 | 0.8 |
| Swap two rows (ms) | 7.3 | 64.8 | 7.8 | 6.3 |
| Building and tearing down | ||||
| Create 1,000 rows (ms) | 88.7 | 62.1 | 59.2 | 52.5 |
| Create 10,000 rows (ms) | 918.9 | 947.3 | 705.8 | 660.4 |
| Clear 10,000 rows (ms) | 108.9 | 96.4 | 75.3 | 62.0 |
| Startup, size, memory | ||||
| Time to first render (ms) | 24.4 | 41.8 | 28.5 | 22.8 |
| App bundle, gzipped (KB) | 8.4 | 59.9 | 24.9 | 4.7 |
| Heap at 10,000 rows (MB) | 40.5 | 20.2 | 19.0 | 12.3 |
Read more details about the benchmarks here.
Release Notes
Find the full release notes and changelogs at: Release v7.1.0 · brnrdog/xote · GitHub
Thank you!