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}
enum Msg {
Inc
Dec
}

fn main {
let app = simple_cell(
model=0,
update=(msg, model) => match msg {
Inc => model + 1
Dec => model - 1
},
view=(emit, model) => div [
h1("\{model}"),
button(on_click=emit(Inc), "+"),
button(on_click=emit(Dec), "-"),
],
)
new(app).mount("app")
}

#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}

struct Model {
value : String
items : Map[String, Bool]
}

enum Msg {
Add
Change(String)
Done(String)
}

/// The todo plan
fn plan(name : String) -> Cell {
@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=(emit, 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=emit(Done(todo)), "done"),
])
})
div(style=["border: 1px solid black", "padding: 1em"], [
h1(name),
ul(items),
input(input_type=Text, value~, on_change=s => emit(Change(s))),
button(on_click=emit(Add), "add"),
])
},
)
}

/// Main app
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=(emit, model) => {
fragment([
div(model.plans.map(x => x.view())),
button(on_click=emit(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

#
Cmd

A deferred command handled by the Rabbita runtime.

Cmd models side effects in the update loop. Commands are returned from update or embedded in Html event handlers, and are executed later by the runtime.

#
Dispatch

using @moonbit-community/rabbita/cmd { type Emit as Dispatch }

#
Emit

Message emitter type used by a Cell.

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

#
Cell

type Cell

A cell that encapsulates model, update, and view.

#
Cell::view

Render this cell as Html.

#
batch

Combine multiple commands into one command.

#
cell

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

Type parameters:

  • Model: a custom type you need to defined, 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 : (Emit[Msg], Msg,Model) -> (Cmd,Model): Describe how to compute new Model from Msg and old Model. This callback also receives an Emit[Msg], which converts a Msg into a Cmd.

  • view : (Emit[Msg], Model) -> Html: Describe how to compute Html from Model. This callback also receives an Emit[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

#deprecated("Use cell_with_emit instead.")
fn[Model, Msg] cell_with_dispatch(model~ : Model, update~ : (
Emit
[Msg], Msg, Model) -> (
Cmd
, Model), view~ : (
Emit
[Msg], Model) ->
Html
, subscriptions? : (
Emit
[Msg], Model) ->
Sub
) -> (
Emit
[Msg], Cell)

#
cell_with_emit

Create a cell and also return its Emit.

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

Type parameters:

  • Model: a custom type you need to defined, 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 : (Emit[Msg], Msg,Model) -> (Cmd,Model): Describe how to compute new Model from Msg and old Model. This callback also receives an Emit[Msg], which converts a Msg into a Cmd.

  • view : (Emit[Msg], Model) -> Html: Describe how to compute Html from Model. This callback also receives an Emit[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.

#
none

A command that does nothing.

#
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

test "render html to string" {
let page = @rabbita.static_cell(
@html.ul(["todo1", "todo2"].map(x => @html.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~ : (
Emit
[Msg], Model) ->
Html
) -> Cell

Create a cell with simplified model/update/view.

Type parameters:

  • Model: a custom type you need to defined, 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 : (Emit[Msg], Model) -> Html: describes how to render Html from Model. This callback also receives an emit 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=emit(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" {
let app = @rabbita.simple_cell(
model=0,
update=(_ : Unit, count) => count + 1,
view=(emit, count) => {
@html.div([
@html.h1("You clicked \{count} times."),
@html.button(on_click=emit(()), "+"),
])
},
)
ignore(app)
}

#
simple_cell_with_dispatch

#deprecated("Use simple_cell_with_emit instead.")
fn[Model, Msg] simple_cell_with_dispatch(model~ : Model, update~ : (Msg, Model) -> Model, view~ : (
Emit
[Msg], Model) ->
Html
, subscriptions? : (
Emit
[Msg], Model) ->
Sub
) -> (
Emit
[Msg], Cell)

#
simple_cell_with_emit

fn[Model, Msg] simple_cell_with_emit(model~ : Model, update~ : (Msg, Model) -> Model, view~ : (
Emit
[Msg], Model) ->
Html
, subscriptions? : (
Emit
[Msg], Model) ->
Sub
) -> (
Emit
[Msg], 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