Introducing Xote 7.1

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!

5 Likes

Congrats on shipping a ReScript-native framework!

Is the JSX deliberately more relaxed than in the React PPX? Do you think we should relax it there as well? Personally I am against that in times of AI coding, but for review less noise is also nice.

Congrats on shipping a ReScript-native framework!

Thanks! It’s been a great journey!

Is the JSX deliberately more relaxed than in the React PPX? Do you think we should relax it there as well? Personally I am against that in times of AI coding, but for review less noise is also nice.

Yes, but XoteJSX is only more relaxed than the React PPX when using the @xote.component notation. And that’s just to solve a specific problem of Xote, because of its reactive model. React doesn’t have the same problem, loosening the JSX wouldn’t give it any benefits I think.

In Xote, without the @xote.component notation, the JSX works pretty much the same as in React JSX, but applied for its context. It renders a View.node. You have to explicitly call the typed view primitive for rendering the node (View.text/View.signalText/View.Text for static/reactive nodes):

<div class={() => Signal.get(theme)}>{View.text("Hello")}</div>
<div>{View.signalText(() => Signal.get(name))}</div>

The whole idea of the PPX was to be able to consume the signal nodes from the view layer in a more “natural way” for developers, by automatically wrapping signals into reactive View.child nodes, which can be either a simple static value or a reactive signal:

<div>{"Hello"}</div>
<div>{Signal.get(name)}</div>

This extends to conditional branches that depend on signals, which makes the usage clearer and easier to read. It’s basically an auto-wrapper kind of thing.

Without the notation, you wrap the block yourself:

<div>
  {View.tracked(() =>
    switch Signal.get(status) {
    | Loading => <span> {View.text("Loading…")} </span>
    | Ready(msg) => <strong> {View.text(msg)} </strong>
    })}
</div>

With it, you just write the switch:

<div>
  {switch Signal.get(status) {
  | Loading => <span> {"Loading…"} </span>
  | Ready(msg) => <strong> {msg} </strong>
  }}
</div>

I’m honestly still evaluating the benefits of the PPX with @xote.component. It’s completely optional, and I think it makes declaring the UI a bit cleaner and simpler. But it comes with the cost of maintaining the PPX, and dealing with its current limitations. With AI, I’m not even sure the gains are relevant.

3 Likes

Does same apply to tag property values, like Preact does with their “signalish” props?

Introducing Signals – Preact?

It applies to tag attributes, yes. HTML attributes are reactive when using signals on them:

<div class={Signal.get(theme)} /> // this works, updates when `theme` changes

For component props, Xote provides a simple MaybeSignal.t to work with something that can be either a static value (string) or a reactive one (Signal), under an API that mirrors the Signal.

let make = (~name: MaybeSignal.t<string>) => {
  <div>{`Greetings, ${MaybeSignal.get(name)}`}</div> // name can be either a plain string or a signal
}
3 Likes

Would you consider extend PPX further still so it can auto unwrap signal values in attributes or in text, or would that be bridge too far?

I considered that, but I’m still not sure.

From one side, I think being able to spot a reactive node right away by reading Signal.get(value) instead of simply value in your UI makes things clearer. You can easily spot whether a text node is being rendered from a static or a reactive value.

From another side, I understand that abstracting this feels more natural for some people, especially coming from React, where any prop change triggers an update down the tree if it’s being rendered.

Also, there are some limitations with the current PPX solution that I would like to improve before making the ppx usage more ~ubiquous. An example of a limitation: the ppx doesn’t unwrap a reactive leaf if the signal is a reference outside the module’s scope.

So I might still consider depending on how the PPX evolves. Someone with ppx expertise would be great help btw.

2 Likes

@moondaddi to the rescue!

2 Likes

I am playing around with Preact signals (with rescript obv) and it does this, despite largely still being React-like in most ways and so do Vue and Svelte

2 Likes

A PPX is simply an AST-to-AST transformer (AST → AST'). If you can share some before-and-after examples of the expressions you’d like to transform, I’d be more than happy to help implement those transformations.

Xote rocks!

3 Likes

Thanks, @moondaddi! I’m cooking something with Claude, so I might ask for some review at some point. I can also share a quick page describing +/- what I’m trying to achieve as well

I am playing around with Preact signals (with rescript obv) and it does this, despite largely still being React-like in most ways and so do Vue and Svelte

@azekeprofit Question: how would you feel about some sort of sigil for rendering signals? Example:

<div class={%theme}> /* direct usage on attributes */
  <div> {%propA} </div>   /* reactive, unwraps into {() => Signal.get(propA)} */
  <div> {propB} </div>    /* static */
</div>

{switch %user { /* tracked conditional branches, unwraps into {View.tracked(() => ...) */
 | None => "Unauthorized"
 | Some(user) => `Welcome, ${user->User.name}`
 }}

This gives us a way to work around the limitation of signals undefined in external modules, since what would indicate the unwrapping would be the % (or any other possible symbol we can use).

2 Likes

Getting a reactive signal value is the only thing one would need when reading a signal inside a prop. If sigil is a parser limitation, i would rather use Signal.get(propA) syntax raw, without the syntax salt.

But Rescript’s walrus operator “:=” (which similarly feels a somewhat superfluous) can look into it’s parameters, so i believe there should (?) be a way for parser to see if the value is of type signal<'v> or 'v and do the only thing it makes sense to do for either case. Recursive analysis over entire AST inside prop would be way too much though, but a simple check if single value inside prop attribute is a signal or not would be enough.

2 Likes

I actually think @xote.component already supports unwrapping signal values directly, at least partially. Any {value} rendered in the JSX, static or not, gets wrapped in a View.child, which then branches into a reactive node if it references a signal. This happens at runtime, the JSX is relaxed for that. So, this example should work:

@xote.component
let make = () => {
  let count = Signal.make(0)                        // Signal.t<int>
  let countClass = Computed.make(() => Signal.get(count) > 0 ? "positive" : "negative") // Signal.t<string>
  let label = "Count: "                             // string

  let increment = _ => Signal.update(count, n => n + 1)

  <div class={countClass}>     // a signal reference: reactive, resolved at runtime
    <div>
      {label}                  // View.child, plain string, static node
    </div>

    <div>
      {count}                  // View.child, signal reference, reactive node
    </div>

    {switch Signal.get(count) {  // this needs explicit read 
    | n when n >= 10 => `${Signal.get(count)->Int.toString} is too much!` // explicit read is also need here, otherwise is type error
    | n when n >= 5 => count // this should work though
    | _ => "Not enough"
    }}

    <button onClick={increment}> {"+"} </button>
  </div>
}

So it works, but not in compound expressions. And that’s the same for signals defined in other module (correcting a statement I made before). You can refer to signals in other modules using this syntax, but you can’t use in compound expressions:

// Store.res
let theme = Signal.make("light")
let unread = Signal.make(3)

// Client.res
<div class={Store.theme}>   // receives the signal, reads it at runtime  
  {`${Store.unread} notifications`} // type error, no automatic coerce into a signal value, even if unread was a string
</div>

I wonder if it’s worth exploring a path where every signal reference is always automatically unwrapped, except when being used under certain conditions (under peek and untrack are good exception examples).

3 Likes

Just to share one more update:

There’s now a Xote Playground available for trying out Xote components :tada:

xote-playground

Right now the ReScript and Xote versions are locked, I might add different version targets in future and improve the error logging.

3 Likes

Super Cool!!!

2 Likes