Lustre Frontend Counter and Form

The Elm Architecture

Types

i
pub type Model {
  Model(count: Int)
}

pub type Msg {
  Increment
  Decrement
  Reset
}

Update

i
pub fn update(model: Model, msg: Msg) -> Model {
  case msg {
    Increment -> Model(model.count + 1)
    Decrement -> Model(model.count - 1)
    Reset -> Model(0)
  }
}

View

i
fn view(model: Model) -> Element(Msg) {
  html.div([], [
    html.p([], [html.text(int.to_string(model.count))]),
    html.button([event.on_click(Increment)], [html.text("+")]),
    html.button([event.on_click(Decrement)], [html.text("-")]),
    html.button([event.on_click(Reset)],     [html.text("reset")]),
  ])
}

Console Demo

i
  let model = init()
  let model = update(model, Increment)
  let model = update(model, Increment)
  let model = update(model, Decrement)
  io.println(view(model))

Running in the Browser

i
pub fn main() {
  let app = lustre.simple(init, update, view)
  let assert Ok(_) = lustre.start(app, "#app", Nil)
}

Handling Forms

i
type Model {
  Model(count: Int, input: String, items: List(String))
}

type Msg {
  Increment
  Decrement
  Reset
  InputChanged(String)
  AddItem
  RemoveItem(Int)
}

Connecting to an API

i
fn fetch_todos() -> effect.Effect(Msg) {
  effect.from(fn(dispatch) {
    // make HTTP request, then:
    dispatch(TodosLoaded(todos))
  })
}

Testing

i
pub fn increment_test() {
  init()
  |> update(Increment)
  |> update(Increment)
  |> fn(m) { m.count }
  |> should.equal(2)
}

pub fn decrement_test() {
  init()
  |> update(Decrement)
  |> fn(m) { m.count }
  |> should.equal(-1)
}

pub fn reset_test() {
  let model = update(init(), Increment) |> update(Increment)
  update(model, Reset).count
  |> should.equal(0)
}

Check Understanding

How is this different from React?

React manages state with hooks (useState, useReducer) inside components. State updates trigger re-renders, but the order of updates and renders can be surprising when multiple state changes happen at once.

Lustre has one model for the whole application. Every event produces exactly one call to update, which returns one new model. The runtime renders once after each update. There are no hooks, no component-local state, and no effect order surprises. The tradeoff is that Lustre applications require more up-front type design (the Msg type must cover every event), but the result is easier to test and reason about.

Why does view return Element(Msg) rather than a raw HTML string?

A typed virtual DOM catches errors the browser would silently ignore. If view could produce any HTML string, you could accidentally fire an event that is not a valid Msg, and update would have no case for it. By parameterising Element on the message type, the compiler verifies that every event handler in the view produces a value that update knows how to handle. This makes adding a new button a type-checked operation, not a runtime surprise.

Exercises

Reset button in view (10 minutes)

Add a Reset button to the HTML view. The button should call event.on_click(Reset) and display "reset". Test by calling update(model, Reset) and confirming the count is 0.

Disable add button (15 minutes)

Add InputChanged(String) and AddItem to Msg. In view, disable the Add button (using attribute.disabled(True)) when model.input == "". Write update tests confirming that InputChanged stores the value and AddItem appends to the list and clears the input.

Colour by sign (10 minutes)

In view, give the count paragraph a CSS class based on its sign: "positive" for > 0, "negative" for < 0, "zero" for == 0. Use attribute.class(...). No runtime changes are needed; write a test that the correct class string is produced for each sign.

Fetch on load (25 minutes)

Add TodosLoaded(List(String)) to Msg and a todos: List(String) field to Model. Write a fetch_todos() effect using lustre/effect.from and gleam_httpc that calls GET /todos and dispatches TodosLoaded. Dispatch the effect from init.