Chai is a MoonBit library for browser applications with a virtual DOM, browser-oriented commands and subscriptions, routing helpers, and encapsulated components.
It is inspired by the core TEA model of Model, Msg, update, and view, but it is not limited to a single flat application loop. Chai adds:
- stateful components with their own local
ModelandMsg - parent/child messaging through
HandleandCmd::send - built-in hash and
pushStaterouting helpers - browser-focused commands and subscriptions for timers, HTTP, keyboard, resize, and navigation events
Chai is split into two packages:
| Package | Import | Purpose |
|---|---|---|
bikallem/chai |
@chai |
Runtime — start, Cmd, Sub, Handle, component, routing, VNode, Attr types |
bikallem/chai/h |
@h |
View helpers — div, text, el, attr, class, on_click, and all other HTML/attribute/event constructors |
Add both to your moon.pkg:
import {
"bikallem/chai",
"bikallem/chai/h",
}
Performance on the js-framework-benchmark operations (median of 5 runs, headless Chromium, lower is better):
| Operation | Chai JS | Chai WASM | Vanilla JS | React 18 | Preact |
|---|---|---|---|---|---|
| Create 1,000 rows | 45ms | 44ms | 61ms | 55ms | 51ms |
| Create 10,000 rows | 518ms | 483ms | 483ms | 620ms | 558ms |
| Append 1,000 rows | 58ms | 57ms | 47ms | 81ms | 74ms |
| Partial update (every 10th) | 23ms | 24ms | 21ms | 30ms | 12ms |
| Clear rows | 5.4ms | 5.4ms | 5.4ms | 12ms | 7.7ms |
| Swap rows | 8.4ms | 7.7ms | 11ms | 52ms | 13ms |
| Replace 1,000 rows | 44ms | 42ms | 44ms | 55ms | 66ms |
| Select row | 3.8ms | 3.3ms | 1.6ms | 4.9ms | 4.6ms |
| Remove row | 10ms | 11ms | 11ms | 14ms | 14ms |
Chai uses keyed virtual DOM diffing with three key optimizations:
- Bulk clear — removes all children in a single
textContent = ""call instead of one-by-oneremoveChild - Common-prefix scan — skips HashMap/LIS overhead when key order is stable (the common case for select, update, append)
lazy_— skips both vdom creation and diffing for rows whose hash hasn't changed
Run the benchmarks yourself:
make benchA minimal counter app:
struct Model { count : Int }
enum Msg { Increment; Decrement }
fn app_init() -> (Model, @chai.Cmd[Msg]) {
({ count: 0 }, @chai.Cmd::none())
}
fn update(model : Model, msg : Msg) -> (Model, @chai.Cmd[Msg]) {
match msg {
Increment => ({ count: model.count + 1 }, @chai.Cmd::none())
Decrement => ({ count: model.count - 1 }, @chai.Cmd::none())
}
}
fn view(model : Model) -> @chai.VNode[Msg] {
@h.div([], [
@h.button([@h.on_click(fn(_e) { Decrement })], [@h.text("-")]),
@h.span([], [@h.text(model.count.to_string())]),
@h.button([@h.on_click(fn(_e) { Increment })], [@h.text("+")]),
])
}
fn subscriptions(_model : Model) -> @chai.Sub[Msg] {
@chai.Sub::none()
}
fn main {
@chai.start(init=app_init, update~, view~, subscriptions~, selector="#app")
}See the src/examples/ directory:
- todo — TodoMVC-style app with input, filtering, and keyed list diffing
- counters — Encapsulated counter components with parent-to-child messaging via
Handle - clock — Stopwatch demonstrating
Sub::every,Sub::on_key_down, andCmd::after - router — Hash-based routing with
Sub::on_hash_changeandhash_link - fetch — HTTP requests with
Cmd::http_get - canvas — Canvas drawing with mouse event subscriptions
- showcase — Combined demo of multiple features
- benchmark — Performance benchmarks
Every Chai app starts from the familiar TEA-style state transition pattern:
- Model — your application state
- Msg — messages that describe state changes
- init — returns the initial
(Model, Cmd[Msg]) - update — takes
(Model, Msg), returns the new(Model, Cmd[Msg]) - view — takes
Model, returnsVNode[Msg] - subscriptions — takes
Model, returnsSub[Msg]for external events
Call start() with these five functions and a CSS selector to mount the app.
Build virtual DOM trees with element constructors from @h. Each takes (attrs, children):
@h.div([@h.class("container")], [
@h.h1([], [@h.text("Title")]),
@h.p([], [@h.text("Content")]),
])Use @h.el(tag, attrs, children) for any HTML tag, or @h.text(s) for text nodes.
For efficient list diffing, wrap children with keys:
@h.ul([], @h.keyed_list(
items.map(fn(item) { (item.id.to_string(), view_item(item)) })
))Use @h.null() when a branch should render nothing. This produces no DOM output — the differ treats it as a no-op.
// Conditional rendering
fn view(model : Model) -> @chai.VNode[Msg] {
@h.div([], [
if model.show_banner {
@h.div([@h.class("banner")], [@h.text("Welcome!")])
} else {
@h.null()
},
@h.p([], [@h.text("Content")]),
])
}
// Optional list items
@h.ul([], items.map(fn(item) {
if item.visible { @h.li([], [@h.text(item.name)]) } else { @h.null() }
}))@h.class("my-class") // HTML class
@h.class_list([("active", is_active), ("hidden", is_hidden)])
@h.id("my-id") // HTML id
@h.type_("checkbox") // HTML type
@h.value("hello") // input value (property)
@h.checked(true) // checkbox checked (property)
@h.placeholder("Type...") // placeholder
@h.disabled(true) // disabled (property)
@h.href("/page") // link href
@h.style("color", "red") // inline style
@h.attr("data-x", "value") // any attributeConvenience helpers extract common values from the event:
@h.on_click(fn(event) { MyMsg }) // click (receives Event)
@h.on_input(fn(value) { Input(value) }) // input (receives String value)
@h.on_change(fn(value) { Changed(value) }) // change (receives String value)
@h.on_check(fn(checked) { Toggle(checked) }) // checkbox (receives Bool)
@h.on_keydown(fn(key) { KeyPress(key) }) // keydown (receives String key name)
@h.on_submit(fn(event) { Submit }) // form submit (calls preventDefault)For full event access on any event type, use the generic handler:
@h.on("mousemove", fn(event) { Move(event) })Cmd::none() // no side effects
Cmd::batch([cmd1, cmd2]) // combine commands
Cmd::task(fn(dispatch) { ... }) // custom async task
Cmd::after(500, DelayedMsg) // dispatch after delay (ms)
Cmd::http_get(url, fn(result) { GotResponse(result) }) // HTTP GET
Cmd::send(handle, child_msg) // send message to child component
Cmd::push_url("/path") // pushState navigation (see Routing)
Cmd::replace_url("/path") // replaceState navigation (see Routing)
Cmd::push_hash("/path") // hash navigation (see Routing)
cmd.map(fn(msg) { Wrapped(msg) }) // transform message typeCmd::http_get dispatches Ok(body) on success or Err(message) on failure.
Sub::none()
Sub::batch([sub1, sub2])
Sub::every(1000, "tick", fn() { Tick }) // recurring timer (ms)
Sub::on_key_down("keys", fn(key) { KeyDown(key) }) // document keydown
Sub::on_window_resize("resize", fn(w, h) { Resized(w, h) }) // window resize
Sub::on_hash_change("url", fn(url) { UrlChanged(url) }) // hash routing (see Routing)
Sub::on_url_change("url", fn(url) { UrlChanged(url) }) // pushState routing (see Routing)
sub.map(fn(msg) { Wrapped(msg) }) // transform message typeEach subscription takes a key string to match it across renders.
Built-in subscriptions (Sub::every, Sub::on_key_down, Sub::on_window_resize, Sub::on_hash_change, Sub::on_url_change) keep the underlying listener/timer alive for the same key and refresh their message-producing behavior in place.
Custom Sub::sub subscriptions are re-initialized when returned again, so captured setup logic stays fresh; they are cleaned up when no longer returned.
For on_key_down, pass prevent_default=fn(key) { ... } to selectively prevent default browser behavior (for example, fn(key) { key == " " } to stop spacebar scrolling).
For custom subscriptions, use Sub::sub directly:
Sub::sub("my-sub", fn(dispatch) {
// set up listener, return cleanup function
fn() { /* cleanup */ }
})Chai provides hash-based and pushState-based routing as subscriptions and commands.
// Read the initial URL
fn app_init() -> (Model, Cmd[Msg]) {
({ route: to_route(hash_url()) }, Cmd::none())
}
// Subscribe to hash changes
fn subscriptions(_model : Model) -> Sub[Msg] {
Sub::on_hash_change("url", fn(url) { UrlChanged(url) })
}
// Navigate with hash links
@chai.hash_link("/about", [@h.class("nav-link")], [@h.text("About")])
// Or navigate programmatically
Cmd::push_hash("/about")fn app_init() -> (Model, Cmd[Msg]) {
({ route: to_route(url()) }, Cmd::none())
}
fn subscriptions(_model : Model) -> Sub[Msg] {
Sub::on_url_change("url", fn(url) { UrlChanged(url) })
}
@chai.link("/about", [@h.class("nav-link")], [@h.text("About")], on_nav=GoAbout)
Cmd::push_url("/about")
Cmd::replace_url("/about")The Url struct is passed to your message handler on every navigation:
pub struct Url {
path : Array[String] // "/foo/bar" → ["foo", "bar"]
query : String // "?x=1" (raw, including ?)
hash : String // "#section" (raw, including #)
}Match on url.path to select routes:
fn to_route(url : Url) -> Route {
match url.path {
[] => Home
["about"] => About
["posts", id] => Post(id)
_ => NotFound
}
}Components are self-contained TEA loops with their own Model and Msg types, embedded as a VNode in the parent tree:
fn counter[ParentMsg]() -> VNode[ParentMsg] {
component(
init=fn() { ({ count: 0 }, Cmd::none()) },
update~,
view~,
)
}Components can have their own subscriptions, just like top-level apps:
fn clock[ParentMsg]() -> VNode[ParentMsg] {
component(
init=fn() { ({ time: 0 }, Cmd::none()) },
update~,
view~,
subscriptions=fn(_model) { Sub::every(1000, "tick", fn() { Tick }) },
)
}Pass id for stable identity in keyed lists. Use Handle for parent-to-child messaging:
let handle = Handle::new()
// In view: component(handle~, init~, update~, view~)
// In update: Cmd::send(handle, ChildMsg)Fast local unit checks:
make checkFull local suite (build + unit + Playwright smoke tests):
make test