chai

Chai is TEA (The Elm Architecture) framework for MoonBit

moon add bikallem/chai@0.1.0
Download zip
Author
Version
0.1.0
License
MIT
Last updated
4 months ago
Downloads
26

Dependencies

README

#Chai

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 Model and Msg
  • parent/child messaging through Handle and Cmd::send
  • built-in hash and pushState routing helpers
  • browser-focused commands and subscriptions for timers, HTTP, keyboard, resize, and navigation events

#Table of Contents

#Packages

Chai is split into two packages:

PackageImportPurpose
bikallem/chai@chaiRuntime — start, Cmd, Sub, Handle, component, routing, VNode, Attr types
bikallem/chai/h@hView 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", }

#Benchmarks

Performance on the js-framework-benchmark operations (median of 5 runs, headless Chromium, lower is better):

OperationChai JSChai WASMVanilla JSReact 18Preact
Create 1,000 rows45ms44ms61ms55ms51ms
Create 10,000 rows518ms483ms483ms620ms558ms
Append 1,000 rows58ms57ms47ms81ms74ms
Partial update (every 10th)23ms24ms21ms30ms12ms
Clear rows5.4ms5.4ms5.4ms12ms7.7ms
Swap rows8.4ms7.7ms11ms52ms13ms
Replace 1,000 rows44ms42ms44ms55ms66ms
Select row3.8ms3.3ms1.6ms4.9ms4.6ms
Remove row10ms11ms11ms14ms14ms

Chai uses keyed virtual DOM diffing with three key optimizations:

  • Bulk clear — removes all children in a single textContent = "" call instead of one-by-one removeChild
  • 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 bench

#Quick Start

A 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")
}

#Examples

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, and Cmd::after
  • router — Hash-based routing with Sub::on_hash_change and hash_link
  • fetch — HTTP requests with Cmd::http_get
  • canvas — Canvas drawing with mouse event subscriptions
  • showcase — Combined demo of multiple features
  • benchmark — Performance benchmarks

#Core Concepts

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, returns VNode[Msg]
  • subscriptions — takes Model, returns Sub[Msg] for external events

Call start() with these five functions and a CSS selector to mount the app.

#API Reference

#Elements

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.

#Keyed Lists

For efficient list diffing, wrap children with keys:

@h.ul([], @h.keyed_list(
items.map(fn(item) { (item.id.to_string(), view_item(item)) })
))

#Null Nodes

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() }
}))

#Attributes

@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 attribute

#Events

Convenience 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) })

#Commands

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 type

Cmd::http_get dispatches Ok(body) on success or Err(message) on failure.

#Subscriptions

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 type

Each 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 */ }
})

#Routing

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")

#pushState routing (requires server-side URL rewriting)

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")

#Url type

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

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)

#Testing

Fast local unit checks:

make check

Full local suite (build + unit + Playwright smoke tests):

make test

#
Attr

type Attr[Msg]

#
Attr::map

fn[A, B] Attr::map(self : Attr[A], f : (A) -> B) -> Attr[B]

#
Cmd

type Cmd[Msg]

#
Cmd::after

fn[Msg] Cmd::after(ms : Int, msg : Msg) -> Cmd[Msg]

Dispatch a message after a delay in milliseconds.

#
Cmd::batch

fn[Msg] Cmd::batch(cmds : Array[Cmd[Msg]]) -> Cmd[Msg]

#
Cmd::http_get

fn[Msg] Cmd::http_get(url : String, on_result : (Result[String, String]) -> Msg) -> Cmd[Msg]

Perform an HTTP GET request. Dispatches on_result(Ok(body)) on success or on_result(Err(message)) on failure.

#
Cmd::map

fn[A, B] Cmd::map(self : Cmd[A], f : (A) -> B) -> Cmd[B]

#
Cmd::none

fn[Msg] Cmd::none() -> Cmd[Msg]

#
Cmd::push_hash

fn[Msg] Cmd::push_hash(hash : String) -> Cmd[Msg]

Set the location hash. Triggers on_hash_change subscribers natively.

#
Cmd::push_url

fn[Msg] Cmd::push_url(url : String) -> Cmd[Msg]

Navigate to a new URL using pushState. Triggers on_url_change subscribers.

#
Cmd::replace_url

fn[Msg] Cmd::replace_url(url : String) -> Cmd[Msg]

Replace the current URL using replaceState. Triggers on_url_change subscribers.

#
Cmd::send

fn[CMsg, Msg] Cmd::send(handle : Handle[CMsg], msg : CMsg) -> Cmd[Msg]

Send a message into a child component's update loop via its Handle.

#
Cmd::task

fn[Msg] Cmd::task(task : ((Msg) -> Unit) -> Unit) -> Cmd[Msg]

#
Handle

type Handle[CMsg]

Opaque handle for sending messages into a child component's update loop.

#
Handle::new

fn[CMsg] Handle::new() -> Handle[CMsg]

#
Sub

type Sub[Msg]

#
Sub::batch

fn[Msg] Sub::batch(subs : Array[Sub[Msg]]) -> Sub[Msg]

#
Sub::every

fn[Msg] Sub::every(ms : Int, key : String, to_msg : () -> Msg) -> Sub[Msg]

Subscribe to a recurring timer. Fires to_msg() every ms milliseconds.

#
Sub::map

fn[A, B] Sub::map(self : Sub[A], f : (A) -> B) -> Sub[B]

#
Sub::none

fn[Msg] Sub::none() -> Sub[Msg]

#
Sub::on_hash_change

fn[Msg] Sub::on_hash_change(key : String, to_msg : (Url) -> Msg) -> Sub[Msg]

Subscribe to hash changes for hash-based routing.

#
Sub::on_key_down

fn[Msg] Sub::on_key_down(key : String, to_msg : (String) -> Msg, prevent_default? : (String) -> Bool) -> Sub[Msg]

Subscribe to keydown events on the document. When prevent_default is given, only keys matching the predicate will have their default browser action suppressed (e.g., scrolling, button activation). Without it, no defaults are prevented.

#
Sub::on_url_change

fn[Msg] Sub::on_url_change(key : String, to_msg : (Url) -> Msg) -> Sub[Msg]

Subscribe to URL changes (popstate events) for pushState-based routing.

#
Sub::on_window_resize

fn[Msg] Sub::on_window_resize(key : String, to_msg : (Int, Int) -> Msg) -> Sub[Msg]

Subscribe to resize events on the window.

#
Sub::sub

fn[Msg] Sub::sub(key : String, start : ((Msg) -> Unit) -> (() -> Unit)) -> Sub[Msg]

#
Url

pub struct Url {
path : Array[String]
query : String
hash : String
}

#
VNode

type VNode[Msg]

#
VNode::map

fn[A, B] VNode::map(self : VNode[A], f : (A) -> B) -> VNode[B]

#
attr

fn[V : Show, Msg] attr(attr_name : String, value : V) -> Attr[Msg]

A generic attribute function for any attribute name and value.

#
component

fn[Model, CMsg, Msg] component(id? : String, handle? : Handle[CMsg], init~ : () -> (Model, Cmd[CMsg]), update~ : (Model, CMsg) -> (Model, Cmd[CMsg]), view~ : (Model) -> VNode[CMsg], subscriptions? : (Model) -> Sub[CMsg]) -> VNode[Msg]

Create a self-contained component VNode. The component's Model and CMsg types are erased via closure capture; the returned VNode works for any parent Msg type. Pass id~ for stable identity in keyed lists.
fn[Msg] el(tag : String, attrs : Array[Attr[Msg]], children : Array[VNode[Msg]]) -> VNode[Msg]

#
error_boundary

fn[Msg] error_boundary(fallback~ : (String) -> VNode[Msg], child~ : () -> VNode[Msg]) -> VNode[Msg]

Wrap a view thunk in an error boundary. If child panics, fallback receives the error message and renders a replacement VNode. On wasm-gc, only raise errors are recoverable; abort traps the module.

#
fragment

fn[Msg] fragment(children : Array[VNode[Msg]]) -> VNode[Msg]

Group multiple VNodes without a wrapper element.
fn[Msg] hash_link(hash : String, attrs : Array[Attr[Msg]], children : Array[VNode[Msg]]) -> VNode[Msg]

Create a link for hash-based navigation. Uses a plain <a href="#/path">.

#
hash_url

fn hash_url() -> Url

Get the current hash fragment parsed as a URL (for hash-based routing).

#
keyed

fn[Msg] keyed(key : String, child : VNode[Msg]) -> VNode[Msg]

#
keyed_list

fn[Msg] keyed_list(items : Array[(String, VNode[Msg])]) -> Array[VNode[Msg]]

Wrap an array of (key, vnode) pairs into Keyed vnodes for use as children of any container element: ul([], keyed_list(items))

#
lazy_

fn[Msg] lazy_(hash : Int, thunk : () -> VNode[Msg]) -> VNode[Msg]

Skip diffing when hash matches the previous render. The thunk is only called when the hash changes.
fn[Msg] link(url : String, attrs : Array[Attr[Msg]], children : Array[VNode[Msg]], on_nav~ : Msg) -> VNode[Msg]

Create a link that uses pushState navigation. Intercepts only plain primary clicks in the same tab and dispatches the given message.

#
null

fn[Msg] null() -> VNode[Msg]

A VNode that renders nothing. Useful for conditional rendering where a branch should produce no output, e.g. if show { div([], [text("hi")]) } else { null() }.
fn[Msg] on(name : String, handler : (
Event
) -> Msg) -> Attr[Msg]

Generic event handler for any event name not covered by helpers.

#
property

fn[Msg] property(name : String, value :
JsValue
) -> Attr[Msg]

A generic DOM property function.

#
start

fn[Model, Msg] start(init~ : () -> (Model, Cmd[Msg]), update~ : (Model, Msg) -> (Model, Cmd[Msg]), view~ : (Model) -> VNode[Msg], subscriptions~ : (Model) -> Sub[Msg], selector~ : String) -> Unit

Start a TEA application

#
style

fn[Msg] style(name : String, value : String) -> Attr[Msg]

A generic style property function.

#
text

fn[S : Show, Msg] text(s : S) -> VNode[Msg]

#
url

fn url() -> Url

Get the current URL from the browser location (for pushState-based routing).