Introduction
Aitne is a lightweight, high-performance reactive web framework built natively for the MoonBit ecosystem. It empowers developers to build type-safe, efficient user interfaces using MBX—a native, JSX-like syntax designed from the ground up for modern web development.
Deeply inspired by reactive frameworks like Leptos and Solid, Aitne bypasses the overhead of a Virtual DOM. Instead, it leverages fine-grained reactivity to compile templates directly into precise, high-performance DOM operations.
Prerequisites
This book is a comprehensive guide to mastering Aitne. To get the most out of it, we assume you are already familiar with: The MoonBit Programming Language, Web Standards (HTML5 and CSS3), The Document Object Model (DOM) and fundamental browser APIs.
The source code of this book is avaliable here.
Getting Started
This chapter will guide you through installing the Aitne CLI toolchain, initializing your first project, and spinning up a local development server.
Before proceeding, ensure you have the MoonBit toolchain installed.
Installation
Aitne provides a lightweight CLI tool to manage project lifecycles, code generation, and compilation tasks.
macOS and Linux
You can install the Aitne CLI automatically by running the following command in your terminal:
curl -fsSL https://raw.githubusercontent.com/Arcelyth/aitne/main/scripts/install.sh | bash
⚠️ Windows Support Note The
aitneCLI relies on moonbit async library that are currently not supported natively on Windows. If you are on Windows, we highly recommend using WSL2 (Windows Subsystem for Linux) to run the toolchain.
Creating a New Project
Once the installation is complete, you can bootstrap a fresh Aitne project using the init subcommand. This will scaffold a standard project directory structure pre-configured for MoonBit and MBX development.
# Initialize a new project named "your_project"
aitne init your_project
cd your_project
Building the Project
To build your project, execute:
aitne build
Under the hood, this command scans your .mbx templates, generates the corresponding MoonBit implementation files, and invokes the MoonBit backend compiler to output optimized JavaScript/WebAssembly artifacts.
Running the Development Server
To see your reactive web application in action, start the built-in high-performance web server:
aitne run
By default, this spins up a local server hosting your application. Open your browser and navigate to: http://localhost:8000.
Configuration via eirene.toml
The behavior of aitne run (such as the binding port, routing rules, and asset directories) is governed by the eirene.toml file generated in your project root. You can modify this file to customize your local development environment.
User Interface
This section covers how to build user interfaces in Aitne. It introduces the MBX format, a markup syntax that allows you to describe UI structures in an HTML-like way while keeping the power of MoonBit.
Components
Writing markups in MBX
MBX is a markup syntax which like JSX but for MoonBit. MBX use .mbx file extension.
You can embed MoonBit code in ‘{}’ block.
fn app() -> &View {
let content = "Let's try something."
<div class="container">
<h1> Welcome To Aitne </h1>
<div> {content} </div>
</div>
}
Creating components
A component is a reusable UI unit. In Aitne, a component is simply a function that returns a &View.
Here is the simple example:
using @dom { trait View }
fn hello_world() -> &View {
<h1>"Hello, World!"</h1>
}
The usage of components are similar to HTML elements. You can write the tag both in the primary name or in PasalCase.
fn app() -> &View {
<hello_world />
<HelloWorld />
}
They can also be nested with each other.
fn app() -> &View {
<div>
<HelloWorld />
</div>
}
Component Naming
In MBX, component tags are converted from PascalCase to snake_case during compilation:
<MyComponent />callsmy_component(...)<HelloWorld />callshello_world(...)
This allows MoonBit function names (which use snake_case) to be called with JSX-like PascalCase tags.
Props
Props pass data into components. In MBX, they look like HTML attributes but compile to function parameters.
Passing Props
Props in MBX are passed as labeled arguments, so the parameter name with ~ or ? in MoonBit maps directly to the attribute name in the template:
fn greeting(text~ : String) -> &View {
<h1>"Hello, \{text}!"</h1>
}
<Greeting text="World" />
String values are written directly. For expressions, use {}:
<Greeting text={user_name} />
Reactive Props
Pass signals as prop values to create reactive data flow. The prop parameter type matches whatever you pass — signal, value, or closure:
fn app() -> &View {
let (name, set_name) = @reactive.create_signal("Alice")
let (age, set_age) = @reactive.create_signal(30)
<UserCard name={name} age={age} />
}
Children
Content between opening and closing tags is passed as the children labeled argument:
fn card(children~ : Array[&View]) -> &View {
<div class="card">{children}</div>
}
<Card>
<h2>"Title"</h2>
<p>"Content"</p>
</Card>
Event Handler Props
Pass event handlers as props using the on: namespace. The handler is received as a labeled argument in the component:
<Btn on:click={handler} />
fn btn(on_click~ : (@ffi.Event) -> Unit) -> &View {
<button on:click={on_click}>"Click"</button>
}
Optional Props
Props with ? defaults can be omitted when calling:
fn card(title~ : String, subtitle? : String = "") -> &View {
...
}
<Card title="Hello" />
<Card title="Hello" subtitle="World" />
Dynamic
Aitne updates the DOM precisely where reactive values change — no Virtual DOM diffing needed.
Reactive Text
A closure inside {} creates a reactive text binding that updates when signals change:
<p>{ () => count.get().to_string() }</p>
Every time count changes, only this text node updates — not the parent element or siblings.
Reactive Attributes
Pass a closure to make an attribute reactive:
<div class={() =>
if selected.get() { "active" } else { "inactive" }
}>
Reactive Properties
Bind DOM properties with value or prop:
<input value={input_val} />
Conditional Rendering
Use standard MoonBit conditionals — no special directives needed:
fn user_greeting(user : User?) -> &View {
match user {
Some(u) => <h1>"Welcome, \{u.name}!"</h1>
None => <h1>"Welcome, Guest!"</h1>
}
}
How It Works
Unlike Virtual DOM frameworks that diff entire trees, Aitne compiles templates into precise DOM operations:
- Each reactive expression targets a specific DOM node or attribute
- When a signal changes, only that exact node or attribute updates
- No diffing, no reconciliation — just targeted updates
Updating one signal in a list of 1000 items causes exactly one text node to change.
Events
Handle user interactions with the on: namespace in MBX.
Basic Handlers
<button on:click={fn(ev) {
println("Button clicked!")
}}>
"Click me"
</button>
Reading Input Values
Use event helpers to read form data:
let (value, set_value) = @reactive.create_signal("")
<input
value={value}
on:input={fn(ev) {
set_value.set(@ffi.event_target_value(ev))
}}
/>
Event Types
| MBX Event | Fires On |
|---|---|
on:click | Element clicked |
on:input | Input value changed |
on:change | Selection changed |
on:submit | Form submitted |
on:keydown | Key pressed down |
on:keyup | Key released |
Window Events
Subscribe to window events with cleanup handled automatically:
@reactive.create_effect(fn(_) {
let cleanup = @dom.on_window_event("resize", fn(ev) {
...
})
@reactive.on_cleanup(cleanup)
})
Preventing Default
Call event_prevent_default in your handler:
<form on:submit={fn(ev) {
@ffi.event_prevent_default(ev)
...
}}>
Routing
Aitne includes a client-side router with nested routes and URL parameters.
Setting Up
Wrap your app in a Router with Routes and Route children:
fn app() -> &View {
<Router base="/">
<Routes fallback={() => <NotFound />}>
<Route path="/" view={() => <Home />} />
<Route path="/about" view={() => <About />} />
</Routes>
</Router>
}
Nested Routes
Use ParentRoute for layouts with child routes rendered inside <Outlet />:
<Router base="/">
<Routes fallback={() => <NotFound />}>
<Route path="/" view={() => <Home />} />
<ParentRoute path="/users" view={() => <UsersLayout />}>
<Route path="/" view={() => <UsersList />} />
<Route path="/:id" view={() => <UserDetail />} />
</ParentRoute>
</Routes>
</Router>
The parent layout uses <Outlet /> where child content appears:
fn users_layout() -> &View {
<div class="users-layout">
<aside>"Sidebar"</aside>
<main>
<Outlet />
</main>
</div>
}
Client-Side Links
Use <Anchor> for navigation without page reloads:
<Anchor href="/about">"About Us"</Anchor>
<Anchor href="/users/123">"User 123"</Anchor>
URL Parameters
Define dynamic segments with : prefix:
<Route path="/users/:id" view={() => <UserDetail />} />
Access the router context to read parameters:
fn user_detail() -> &View {
let ctx = @dom.use_router_context()
// ctx contains current_url, location, etc.
<div>"User Detail"</div>
}
Lists
Use for_node to render reactive lists. When items are added, removed, or reordered, only the affected DOM nodes change.
Basic Usage
<ul>
{for_node(
() => items.get(),
item => item,
item => {
<li>{ () => item }</li>
}
)}
</ul>
Three arguments:
- Data source — a function returning the current array
- Key function — maps each item to a unique identifier
- Render function — renders each item to a view
Todo Example
fn todo_app() -> &View {
let (input_val, set_input_val) = @reactive.create_signal("")
let (items, set_items) = @reactive.create_signal(["Example."])
let add_item = fn(_ev) {
let new_item = input_val.get()
if new_item != "" {
set_items.update(fn(list) { list + [new_item] })
set_input_val.set("")
}
}
let remove_item = fn(item : String) {
set_items.update(fn(list) { list.filter(fn(i) { i != item }) })
}
<div>
<h1>"Todo List"</h1>
<p>Count: { () => items.get().length().to_string() }</p>
<input
type="text"
value={input_val}
on:input={fn(ev) { set_input_val.set(@ffi.event_target_value(ev)) }}
/>
<button on:click={add_item}>"Add"</button>
<ul>
{for_node(
() => items.get(),
item => item,
item => {
<li>
{() => item}
<button on:click={(_) => remove_item(item)}>"Delete"</button>
</li>
}
)}
</ul>
</div>
}
Keyed Updates
Items are tracked by their key, not their index. This means:
- Adding an item at the start doesn’t re-render existing items
- Removing an item only removes its DOM node
- Reordering only moves DOM nodes without recreating them
How It Works
When the data array changes, Aitne computes the difference between the old and new key sets. It then applies only the necessary DOM changes — insertions, removals, and moves — directly, without touching unchanged nodes.
Concept
This section covers the core reactive primitives that power Aitne applications. Understanding these concepts is essential for building efficient, responsive UIs.
- Signal — The fundamental unit of reactive state
- Effect — Side effects that respond to state changes
- Memo — Derived, cached computations
- Context — Dependency injection through the component tree
- Owner — Lifecycle management and resource cleanup
Together, these primitives form a complete fine-grained reactivity system that eliminates the need for a Virtual DOM while providing predictable, efficient updates.
Signal
Signals are the foundation of reactivity in Aitne. Think of a signal as a piece of state that, when read inside a reactive context, automatically tracks as a dependency. When the signal’s value changes, everything that depends on it updates automatically — no manual wiring needed.
Creating a Signal
Signals are created in pairs. The most common way is create_signal, which returns a tuple of a read signal and a write signal:
let (count, set_count) = @reactive.create_signal(0)
count— read the current valueset_count— set a new value
Reading in MBX
In MBX, reading a signal inside a {} block creates a reactive binding. A closure () => ... makes it update automatically when the signal changes:
<p>{ () => count.get().to_string() }</p>
A plain expression (non-closure) is evaluated once:
<p>{count.get().to_string()}</p>
Writing
Write to a signal using set or update:
set_count.set(5) // replace value
set_count.update(|v| v + 1) // transform value
A Complete Example
fn counter() -> &View {
let (count, set_count) = @reactive.create_signal(0)
<div>
<p>{ () => count.get().to_string() }</p>
<button on:click={(_) => set_count.set(count.get() + 1)}>+1</button>
<button on:click={(_) => set_count.update(|v| v - 1)}>-1</button>
</div>
}
fn main {
let _ = @dom.mount_to_body(fn() { counter() })
}
When the user clicks +1, only the <p> text updates — not the whole page. This is fine-grained reactivity.
How It Works Under the Hood
Aitne’s signals form a dependency graph. When you read a signal inside a reactive scope (like {} with a closure, or an effect), the signal registers that scope as a subscriber. When the signal changes, it notifies all subscribers, which then re-execute.
Effect
An effect is a function that automatically re-runs whenever its tracked signal dependencies change. Effects are useful for side effects — logging to the console, making API calls, or synchronizing state with non-reactive systems.
Creating an Effect
let (count, set_count) = @reactive.create_signal(0)
@reactive.create_effect(fn(_prev) {
println("Count changed to: \{count.get()}")
})
set_count.set(1) // triggers the effect
The effect runs immediately on creation, then again whenever count changes.
What Gets Tracked
During an effect’s execution, any signal you read via .get() is automatically registered as a dependency. If a changes, but not b, the effect still re-runs because it read both:
@reactive.create_effect(fn(_) {
println("Sum: \{a.get() + b.get()}")
})
Cleanup
Effects can register cleanup to run before the next execution or when the owning scope is disposed. This prevents stale callbacks and memory leaks:
@reactive.create_effect(fn(_) {
let id = @ffi.set_timeout(fn() {
println("Delayed log")
}, 1000)
@reactive.on_cleanup(fn() {
@ffi.clear_timeout(id)
})
})
Batching
When updating multiple signals, wrap them in batch so effects run only once after all updates:
@reactive.batch(fn() {
set_a.set(100)
set_b.set(200)
set_c.set(300)
})
Memo
A memo is a derived value — it computes a result from other signals and caches it. Memos are lazy: they only recompute when their dependencies change and their value is actually requested. This makes them efficient for expensive computations.
Creating a Memo
let (a, set_a) = @reactive.create_signal(1)
let (b, set_b) = @reactive.create_signal(2)
let sum = @reactive.Memo::new(fn(_prev) {
a.get() + b.get()
})
sum now tracks a and b as dependencies. Reading sum.get() returns the cached value or triggers recomputation if a or b changed.
When to Use Memos
Use memos when:
- A value is derived from multiple signals and used in several places
- The computation is expensive (filtering, formatting, transforming data)
- You want to avoid redundant work
Without a memo, the computation runs every time a component re-renders. With a memo, it runs once and caches.
Glitch-Free Propagation
Aitne’s reactive system handles diamond dependencies correctly. If A and B depend on Source, and C depends on both A and B, then C only recomputes once when Source changes, always seeing consistent values for A and B.
Context
Context allows passing data through the component tree without threading it through every level as props. This is useful for shared concerns like theming, user authentication, or router state.
Providing Context
In a parent component, make a value available to all descendants:
struct Theme { background : String; color : String }
fn app() -> &View {
let theme = Theme::new(background: "#333", color: "#fff")
@reactive.provide_context(theme)
// children can now access the theme
}
Using Context
Any descendant can retrieve the nearest matching context:
fn themed_button() -> &View {
let theme = @reactive.use_context::<Theme>()
match theme {
Some(t) => <button style={"background: \{t.background}; color: \{t.color}"}>"Click"</button>
None => <button>"Click"</button>
}
}
use_context returns Some(value) if a context of that type exists in the owner chain, or None otherwise.
Resolution Behavior
Context walks up the reactive owner tree. The nearest matching value wins. A child component can override a context from a parent by providing a new value of the same type.
Context vs Props
- Props — explicit, passed directly to a component
- Context — implicit, propagated automatically through the tree
Use props for component-specific data. Use context for cross-cutting concerns that many components share.
Owner
Every reactive node in Aitne belongs to an owner. The owner tree manages lifecycle — when an owner is cleaned up, all its children, effects, and resources are disposed automatically.
Why Ownership Matters
When components are created and destroyed (e.g., navigating between pages), their resources must be cleaned up: event listeners removed, timers cleared, network requests aborted. The owner system handles this automatically.
How It Works
The owner tree mirrors the component tree:
- When a component is created, it gets an owner
- Effects, signals, and memos created inside a component are scoped to that owner
- When a component is removed, its owner is disposed, which cleans up everything under it
Manual Cleanup
Effects can register cleanup callbacks that run when their owner is disposed:
@reactive.create_effect(fn(_) {
let timer = @ffi.set_timeout(fn() { /* ... */ }, 1000)
@reactive.on_cleanup(fn() {
@ffi.clear_timeout(timer)
})
})
This pattern is essential for timers, subscriptions, and any resource that needs explicit teardown.
Automatic Cleanup in Views
When a view is mounted with mount_to_body and later destroyed, the owner system automatically disposes everything created during that view’s lifetime. You only need to write on_cleanup for manual resources.