Skip to content

View

How the View module and JSX components render once and stay reactive over time.

A Xote component is a function that returns a View.node. The component usually runs once, sets up its reactive graph, and then reactive nodes update in place over time.

The docs use JSX for examples because it keeps component structure close to the HTML it produces. The lower-level View API still exists for runtime primitives such as reactive text, keyed lists, and mounting.

View Module

Think in two layers:

  • Static structure: the component function builds the node tree
  • Reactive bindings: signal reads inside reactive nodes, computeds, and effects keep specific parts up to date

Using View

JSX Configuration

To use JSX with Xote, point ReScript at XoteJSX. The standard setup also enables the @xote.component annotation — fine-grained reactive components — via Xote's native PPX:

1{
2 "dependencies": ["xote"],
3 "jsx": {
4 "version": 4,
5 "module": "XoteJSX"
6 },
7 "ppx-flags": ["xote/ppx/ppx"],
8 "compiler-flags": ["-open Xote"]
9}

No toolchain or build step is needed: the npm package ships the PPX as a prebuilt binary for linux-x64, linux-arm64, darwin-x64, darwin-arm64 and win32-x64, and Xote's install script selects the one for your platform. Two situations need a manual step:

  • If your package manager skips dependency install scripts (pnpm does by default), allow them for xote (for pnpm, pnpm approve-builds) or run node node_modules/xote/ppx/postinstall.js once after installing.
  • On a platform without a prebuilt binary, the install script compiles the PPX from its bundled source instead, which needs ocamlopt (sh node_modules/xote/ppx/build.sh runs it manually).

Prefer no PPX at all? Omit ppx-flags and use @jsx.component instead of @xote.component. You then write reactive attributes/text with explicit () => … thunks, and bare {…} children (a PPX feature) become the explicit value primitives (<View.Text>, <View.Int>, …) shown under Reactive Output.

Writing Components

Use a module with a make function and annotate it with @xote.component. It derives the props shape from labeled arguments (exactly like @jsx.component) and fine-grains the returned JSX, so you read signals inline — no () => … thunks.

1open Xote
2
3let highlight = Signal.make(false)
4
5module Greeting = {
6 @xote.component
7 let make = (~name: string) => {
8 <div class={Signal.get(highlight) ? "greeting strong" : "greeting"}>
9 <h1> <View.Text> {`Hello, ${name}`} </View.Text> </h1>
10 </div>
11 }
12}
13
14let app = () => {
15 <Greeting name="World" />
16}

Only one component per module (inherited from @jsx.component) — put each in its own file, or a submodule.

JSX Components

1open Xote
2
3module Greeting = {
4 @xote.component
5 let make = (~name: string) => {
6 <section class="greeting-card">
7 <h2> <View.Text> {`Hello, ${name}`} </View.Text> </h2>
8 <p> <View.Text> "This component is ordinary ReScript plus JSX." </View.Text> </p>
9 </section>
10 }
11}

Reactive Output

Under @xote.component, a bare {…} child — a string, a number, or a signal read — is the ergonomic default. The annotation coerces it to a reactive text node (via View.child), so no value-primitive wrapper is needed:

1let count = Signal.make(0)
2
3@xote.component
4let make = () =>
5 <div>
6 {"Count: "}
7 {Signal.get(count)}
8 </div>

Only the number re-renders when count changes; the <div> and the static "Count: " text are built once.

The explicit primitives — View.Text, View.Int, View.Float, View.Bool — remain available and are what you use without the PPX (or when you want the stronger int/float typing on the child). Their children can be raw values, signals, MaybeSignal.t values, or computed functions. For arrays and lists, use View.For; pass by when item identity matters.

1// equivalent, without the annotation:
2<div>
3 <View.Text> "Count: " </View.Text>
4 <View.Int> {count} </View.Int>
5</div>

When a formatted string depends on a value that can be static or reactive, keep the prop as MaybeSignal.t and read it inside a function child.

1@xote.component
2let make = (~name: MaybeSignal.t<string>) => {
3 <View.Text> {() => `Hello, ${MaybeSignal.get(name)}`} </View.Text>
4}

Attributes and Events

In JSX, common HTML props are exposed directly. An attribute accepts a plain value, a signal, or a unit => 'a function — no wrapper needed. Pass a function when the value is derived from one or more signals; a bare expression like Signal.get(isActive) ? "a" : "b" is read once at construction and never updates.

1let isActive = Signal.make(false)
2
3let toggle = (_evt: Dom.event) => {
4 Signal.update(isActive, active => !active)
5}
6
7<button
8 class={() => Signal.get(isActive) ? "btn active" : "btn"}
9 onClick={toggle}>
10 <View.Text> "Toggle" </View.Text>
11</button>

A signal can also be passed straight through when no formatting is needed:

1let theme = Signal.make("dark")
2
3<div class={theme}> <View.Text> "Themed" </View.Text> </div>

In JSX, use class, not className. Use type_ for the HTML type attribute because type is reserved in ReScript.

Lists

Use View.For for simple arrays that can be fully re-rendered. Add by when item identity matters. Unlike element attributes, each has a declared type, so it takes a MaybeSignal.t: MaybeSignal.static(value) for plain data and MaybeSignal.reactive(signal) for reactive data. The same is true of every prop with a declared type, including the ones on components you write.

1let items = Signal.make(["Apple", "Banana", "Cherry"])
2
3<ul>
4 <View.For
5 each={MaybeSignal.reactive(items)}
6 render={item => <li> <View.Text> {item} </View.Text> </li>}
7 />
8</ul>
1type todo = {id: string, text: string}
2let todos = Signal.make([
3 {id: "1", text: "Write docs"},
4 {id: "2", text: "Ship release"},
5])
6
7<ul>
8 <View.For
9 each={MaybeSignal.reactive(todos)}
10 by={todo => todo.id}
11 render={todo => <li> <View.Text> {todo.text} </View.Text> </li>}
12 />
13</ul>

Choose stable keys. Database IDs and route slugs are good. Array indexes are not.

Conditional Output

Use View.Show for boolean branches, View.Maybe for option values, and View.Value when a whole node should be re-rendered from one static or reactive value. Use View.Text, View.Int, View.Float, and View.Bool for direct value output.

1<View.Show when_={MaybeSignal.reactive(isReady)} fallback={<p> <View.Text> "Loading" </View.Text> </p>}>
2 <p> <View.Text> "Ready" </View.Text> </p>
3</View.Show>
4
5<View.Maybe
6 value={MaybeSignal.reactive(selectedTodo)}
7 fallback={<p> <View.Text> "No todo selected" </View.Text> </p>}
8 render={todo => <p> <View.Text> {todo.text} </View.Text> </p>}
9/>
10
11<View.Value
12 value={MaybeSignal.reactive(count)}
13 render={count =>
14 <p>
15 <View.Text> "Count: " </View.Text>
16 <View.Int> {count} </View.Int>
17 </p>
18 }
19/>
20
21<p>
22 <View.Text> "Count: " </View.Text>
23 <View.Int> {count} </View.Int>
24 <View.Text> ", ready: " </View.Text>
25 <View.Bool> {isReady} </View.Bool>
26</p>

Auto-tracked Blocks

When one block of UI depends on several signals at once, the primitives above require you to wire each dependency explicitly — a MaybeSignal.reactive here, a computed there. View.tracked is the escape hatch for those cases: every signal read while its body runs subscribes the block automatically, and the block re-renders when any of them changes.

1let loggedIn = Signal.make(false)
2let name = Signal.make("Ada")
3
4{View.tracked(() =>
5 if Signal.get(loggedIn) {
6 <p> <View.Text> {`Hello, ${Signal.get(name)}`} </View.Text> </p>
7 } else {
8 <p> <View.Text> "Please log in" </View.Text> </p>
9 }
10)}

Dependencies are re-discovered on every run, so conditional reads work: above, name is only tracked while loggedIn is true.

The tradeoff is granularity. A tracked block replaces its children wholesale when a dependency changes — there is no diffing, and local DOM state inside the block (like input focus) does not survive an update. Keep tracked blocks small, and reach for View.Show, View.Value, or View.For with by when a more targeted primitive fits.

How @xote.component stays fine-grained

@xote.component (set up in JSX Configuration above) is how the demos on this site are written, and the recommended way to write reactive components. Its semantics are still settling, so check the limitations before it becomes load-bearing in your app. It replaces @jsx.component: it derives props from the labeled args and fine-grains the returned JSX.

1@xote.component
2let make = () => {
3 <div class={Signal.get(active) ? "on" : "off"}>
4 <View.Text> {`Hello, ${Signal.get(name)}`} </View.Text>
5 </div>
6}

Rather than wrapping the whole block in one computed, the PPX decomposes it: each attribute or text that reads a signal becomes its own reactive leaf (a computedAttr / reactive text node), and View.tracked is emitted only around a child region whose node structure varies (an if/switch), tracking just the condition. The enclosing elements are built once and keep their DOM identity — so it stays fine-grained even for large components.

The PPX is a small native binary that ships prebuilt in the npm package; enabling it is one ppx-flags line in your rescript.json (see JSX Configuration above). Projects that do not add the flag are unaffected — Xote's own published sources compile without it. See ppx/README.md for the decomposition rules, signal-detection details, and limitations.

Mounting

Use View.mount when you already have a DOM element, or View.mountById when you want to look one up by id.

1let app = () => {
2 <div> <View.Text> "Hello, Xote" </View.Text> </div>
3}
4
5View.mountById(app(), "app")

In Practice

Example: Todo List

This example keeps the shape simple while still showing composition: an input form, a summary, and a keyed list of items all built from small components.

TodoList.res
1open Xote
2
3type todo = {
4id: string,
5title: string,
6done: bool,
7}
8
9let todos = Signal.make([
10{id: "1", title: "Write the View guide", done: true},
11{id: "2", title: "Add a runnable example", done: false},
12])
13
14let draft = Signal.make("")
15
16module TodoComposer = {
17@xote.component
18let make = () => {
19 <div>
20 <input onInput={handleInput} value={() => Signal.get(draft)} />
21 <button onClick={addTodo}> <View.Text> "Add" </View.Text> </button>
22 </div>
23}
24}
25
26module TodoRow = {
27@xote.component
28let make = (~todo: todo) => {
29 <li>
30 <span> <View.Text> {todo.title} </View.Text> </span>
31 <input type_="checkbox" checked={todo.done} />
32 </li>
33}
34}
35
36@xote.component
37let make = () => {
38<div>
39 <TodoComposer />
40 <ul>
41 <View.For
42 each={MaybeSignal.reactive(todos)}
43 by={todo => todo.id}
44 render={todo => <TodoRow todo />}
45 />
46 </ul>
47</div>
48}
View composition

Todo list

One signal drives a small set of focused components.

2 tasks left
3 total
  • Learn ReScript
  • Learn signals
  • Write my first Xote component

Working Style

Best Practices

  • Default to the JSX module pattern when there is no reason to drop lower.
  • Keep state close to where it is used. Local signals are cheap and usually easier to follow.
  • Pass by to View.For for JSX collections that reorder, insert, or preserve local DOM state.
  • Be explicit about reactive output so the update boundaries stay readable in the component.

Next Steps