rabbita

functional Web UI framework for MoonBit

functional
html
UI
web
TEA
moon add moonbit-community/rabbita@0.15.2
Download zip
Version
0.15.2
License
Apache-2.0
Last updated
21 hours ago
Downloads
51K

Dependencies

README

#Rabbita

A declarative, functional web UI framework inspired by The Elm Architecture.

This project was previously named Rabbit-TEA and is now renamed to rabbita .

#Features

  • Predictable flow

    State changes follow a single, predictable update path, with explicit side‑effect management.

  • Strict Types

    Rigorous types. No Any sprawl. No stringly-typed APIs.

  • Balanced bundle size

    ~15 KB min+gzip, includes streaming VDOM diff and the MoonBit standard library (DCE via moonc).

  • Modular

    Use Cell to split logic and reuse stateful views. Skip diff and patching for non-dirty cells.

#Quick Start

Use the rabbita-template to get started quickly.

#Examples

#Counter

///|
using @html {div, h1, button}

///|
#cfg(target="js")
test {
struct Model {
count : Int
}
enum Msg {
Inc
Dec
}
let app = @rabbita.simple_cell(
model={ count: 0 },
update=(msg, model) => {
let { count } = model
match msg {
Inc => { count: count + 1 }
Dec => { count: count - 1 }
}
},
view=(dispatch, model) => {
div([
h1("\{model.count}"),
button(on_click=dispatch(Inc), "+"),
button(on_click=dispatch(Dec), "-"),
])
},
)
new(app).mount("main")
}

#Multiple cells

Each cell maintains its own model, view, and update logic, and only dirty cells need VDOM diffing and patching.

///|
using @html {fragment, input, nothing, ul, li, p}

///|
using @list {type List, empty}

///|
/// The todo plan
fn plan(name : String) -> Cell {
struct Model {
value : String
items : Map[String, Bool]
}
enum Msg {
Add
Change(String)
Done(String)
}
@rabbita.simple_cell(
model={ value: "", items: {} },
update=(msg, model) => {
let { value, items } = model
match msg {
Add => { value: "", items: items..set(value, false) }
Done(key) => { ..model, items: items..set(key, true) }
Change(value) => { ..model, value, }
}
},
view=(dispatch, model) => {
let { value, items } = model
let items = items.map((todo, done) => {
let text_style = if done { "text-decoration: line-through" } else { "" }
li(style=[text_style], [
p(todo),
button(on_click=dispatch(Done(todo)), "done"),
])
})
div(style=["border: 1px solid black", "padding: 1em"], [
h1(name),
ul(items),
input(
input_type=Text,
value~,
on_change=s => dispatch(Change(s)),
nothing,
),
button(on_click=dispatch(Add), "add"),
])
},
)
}

///|
/// Main app
#cfg(target="js")
test {
struct Model {
plans : List[Cell]
}
enum Msg {
NewPlan
}
let app = @rabbita.simple_cell(
model={ plans: empty() },
update=(msg, model) => {
let id = model.plans.length()
match msg {
NewPlan => { plans: model.plans.add(plan("plan \{id}")) }
}
},
view=(dispatch, model) => {
fragment([
div(model.plans.map(x => x.view())),
button(on_click=dispatch(NewPlan), "new plan"),
])
},
)
@rabbita.new(app).mount("app")
}

Cell is an opaque model: it is still managed by the outer model, but internal details are hidden. Cell::view() is a pure function that maps state to HTML.

Unlike the hooks-style mental model, a cell's lifecycle is explicit: if its view is not present in the real DOM, the cell is inactive and messages to it are ignored. If the model is removed from the outer model, the cell is destroyed by the garbage collector.

#Used By

#
Dispatch

Message dispatcher type used by a Cell.

Calling dispatch(msg) returns a Cmd that enqueues msg into the cell's update loop.

#
App

type App

A running Rabbita application.

#
App::mount

fn App::mount(self : App, element_id : String) -> Unit

Mount the app into the DOM element identified by element_id.

This initializes the runtime and schedules the first render flush. If routing is configured, the current URL is also dispatched on mount.

#
App::with_init

#internal(unstable, "Experimental API")
fn App::with_init(self : App, cmd :
Cmd
) -> Unit

Registers a one-shot command to be queued when mount runs. This API is still evolving and may change in future releases.

#
App::with_route

Configure routing callbacks for this app.

  • url_changed is triggered for browser history navigation and when push_url / replace_url commands are used.
  • url_request is triggered when captured links @html.a() are clicked.

If the app is already mounted, a url_changed command for the current URL will be enqueued immediately.

#
Cell

type Cell

A cell that encapsulates model, update, and view.

#
Cell::view

Render this cell as Html.

#
attempt

fn[A, E : Error] attempt(msg : (Result[A, E]) ->
Cmd
, f : async () -> A raise E) ->
Cmd

Create a command that runs an async function and handles errors.

Similar to perform, but captures errors and passes Result[A, E] to msg.

#
cell

Create a stateful cell with full model/update/view functionality.

Type parameters:

  • Model: a custom type you need to defiend, represent the state of this cell.
  • Msg: a custom enum you need to defined, represent the events of this cell.

Parameters:

  • model : Model: initial Model value of the cell.

  • update : (Dispatch[Msg], Msg,Model) -> (Cmd,Model): Describe how to compute new Model from Msg and old Model. This callback also receives a Dispatch[Msg], which converts a Msg into a Cmd.

  • view : (Dispatch[Msg], Model) -> Html: Describe how to compute Html from Model. This callback also receives a Dispatch[Msg], which converts a Msg into a Cmd.

The update loop

┌─────────────────┐ │ ▼ │ ┌──────────┐ │ │ user │ │ └────┬─────┘ │ │ msg,model │ ▼ │ ┌────────────┐ msg,model │ │ update() │◄────────────┐ │ └──┬───────┬─┘ │ │ none,model│ │ │ │ ▼ │ │ │ ┌────────┐ │cmd,model │ │ │ view() │ │ │ │ └────┬───┘ │ │ │ │ │ │ │ html│ ▼ │ │ │ ┌─────────┐ │ └──────────────┘ │ runtime │─────────┘ └─────────┘

This update loop is a bit more complex than simple_cell, but follows the same model -> update -> view flow.

The difference is that update also returns a command representing a managed side effect. That side effect is executed by the runtime and may produce another message.

#
cell_with_dispatch

Create a cell and also return its Dispatch.

This is useful when messages need to be sent from outside.

Type parameters:

  • Model: a custom type you need to defiend, represent the state of this cell.
  • Msg: a custom enum you need to defined, represent the events of this cell.

Parameters:

  • model : Model: initial Model value of the cell.

  • update : (Dispatch[Msg], Msg,Model) -> (Cmd,Model): Describe how to compute new Model from Msg and old Model. This callback also receives a Dispatch[Msg], which converts a Msg into a Cmd.

  • view : (Dispatch[Msg], Model) -> Html: Describe how to compute Html from Model. This callback also receives a Dispatch[Msg], which converts a Msg into a Cmd.

The update loop

┌─────────────────┐ │ ▼ │ ┌──────────┐ │ │ user │ │ └────┬─────┘ │ │ msg,model │ ▼ │ ┌────────────┐ msg,model │ │ update() │◄────────────┐ │ └──┬───────┬─┘ │ │ none,model│ │ │ │ ▼ │ │ │ ┌────────┐ │cmd,model │ │ │ view() │ │ │ │ └────┬───┘ │ │ │ │ │ │ │ html│ ▼ │ │ │ ┌─────────┐ │ └──────────────┘ │ runtime │─────────┘ └─────────┘

This update loop is a bit more complex than simple_cell, but follows the same model -> update -> view flow.

The difference is that update also returns a command representing a managed side effect. That side effect is executed by the runtime and may produce another message.

#
delay

Delay execution of a command by ms milliseconds.

The delayed command is added back to the message queue after the timer fires.

#
effect

fn effect(f : async () -> Unit noraise) ->
Cmd

Create a command that runs an effectful async function

#
new

fn new(root : Cell) -> App

Create an application from a root Cell.

Use simple_cell(), static_cell(), cell(), cell_with_disptach() to create a cell. Call mount to attach it to a DOM element. Optionally call with_route to install routing callbacks.

For beginners, simple_cell is recommended. See its documentation for more details.

Example

test "minimal static page" {
let app = @rabbita.static_cell(div("hello world"))
ignore(app)
// use `@rabbita.new(app).mount("id")` in client
}

#
none

A command that does nothing.

#
perform

Create a command that runs an async function.

The async function f is executed, then its result is converted into a new command by msg and scheduled back into the update loop.

#
render_to_string

#internal(unstable, "Experimental API")
fn render_to_string(root : Cell) -> String

Render a Cell tree into an HTML string.

This is the server-side rendering entrypoint for Rabbita. It evaluates view and serializes the resulting virtual DOM into static HTML.

Hydration is currently not supported. If you later call mount on the client, Rabbita will do a fresh client-side render instead of attaching to the existing server-rendered DOM.

Example

#warnings("-alert_unstable")
test "render html to string" {
enum Msg {}
struct Model {
todos : Array[String]
}
let page = @rabbita.simple_cell(
model={ todos: ["todo1", "todo2"] },
update=(_ : Msg, model) => model,
view=(_, model) => ul(model.todos.map(x => li(x))),
)
inspect(
@rabbita.render_to_string(page),
content=(
#|<ul><li>todo1</li><li>todo2</li></ul>
),
)
}

#
simple_cell

fn[Model, Msg] simple_cell(model~ : Model, update~ : (Msg, Model) -> Model, view~ : ((Msg) ->
Cmd
, Model) ->
Html
) -> Cell

Create a cell with simplified model/update/view.

Type parameters:

  • Model: a custom type you need to defiend, represent the state of this cell.
  • Msg: a custom enum you need to defined, represent the events of this cell.

Parameters:

  • model : Model: initial Model value of the cell.
  • update : (Msg, Model) -> Model: describe how to compute new Model from Msg and old Model.
  • view : (Dispatch[Msg], Model) -> Html: describes how to render Html from Model. This callback also receives a dispatch argument, which converts a Msg into a Cmd.

The update loop

┌──────┐ │ user │◀─────────┐ └──────┘ │ │ msg, model │ ▼ │ ┌──────────┐ │ │ update() │ │ html └──────────┘ │ │ model │ ▼ │ ┌────────┐ │ │ view() │─────────┘ └────────┘

At startup, the initial model is rendered as Html by view. The rendered HTML can include message-producing command, for example:

button(on_click=dispatch(MyMsg), "click me")

When the user clicks the button, MyMsg is sent to update with the current model. update then computes the next model from that message, and the new model is rendered again by view.

Example

test "counter" {
struct Model {
count : Int
}
enum Msg {
Click
}
let app = @rabbita.simple_cell(
model={ count: 0 },
update=(msg, model) => {
match msg {
Click => { count: model.count + 1 }
}
},
view=(dispatch, model) => {
div([
h1("You clicked \{model.count} times."),
button(on_click=dispatch(Click), "+"),
])
},
)
ignore(app) // use `new(app).mount("id")` in client
}

#
simple_cell_with_dispatch

fn[Model, Msg] simple_cell_with_dispatch(model~ : Model, update~ : (Msg, Model) -> Model, view~ : ((Msg) ->
Cmd
, Model) ->
Html
) -> ((Msg) ->
Cmd
, Cell)

#
static_cell

Create a static cell that always renders the same Html.

Rather than use static_cell, it's recommend to wrap a function that return html directly:

fn button(text : String, on_click~ : Cmd) -> Html {
@html.button(style=["..."], on_click~, text)
}

Source Files