Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 aitne CLI 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 /> calls my_component(...)
  • <HelloWorld /> calls hello_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:

  1. Each reactive expression targets a specific DOM node or attribute
  2. When a signal changes, only that exact node or attribute updates
  3. 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 EventFires On
on:clickElement clicked
on:inputInput value changed
on:changeSelection changed
on:submitForm submitted
on:keydownKey pressed down
on:keyupKey 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>
}

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:

  1. Data source — a function returning the current array
  2. Key function — maps each item to a unique identifier
  3. 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 value
  • set_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.