Lustre Frontend Counter and Form
- Lustre compiles Gleam to JavaScript and runs the Elm architecture in the browser.
- The model is a plain record;
updateis a pure function that returns a new model;viewis a pure function that returns a virtual DOM tree. - The runtime owns the event loop:
it calls
updatewhen a message arrives andviewto render the result. - There is no
setState, no hooks, and no mutation: all state lives in the model and all logic lives inupdate. - The HTML DSL uses Gleam functions (
html.div,html.button) rather than a template language.
The Elm Architecture
- Lustre brings the Elm architecture to Gleam with three parts:
- Model: the application state, a plain record
- Update: a pure function
(Model, Msg) -> Modelthat handles every user action - View: a pure function
Model -> Element(Msg)that produces a virtual DOM tree
- The Lustre runtime renders the view, listens for events,
converts them to
Msgvalues, callsupdate, re-renders, and repeats - The application code never touches the DOM directly
Types
pub type Model {
Model(count: Int)
}
pub type Msg {
Increment
Decrement
Reset
}
Model(count: Int)is the entire application stateMsglists every action the user can take:Increment,Decrement,Reset- Adding a new feature means adding a variant to
Msgand a branch toupdate- The compiler flags every case that is not handled
Update
pub fn update(model: Model, msg: Msg) -> Model {
case msg {
Increment -> Model(model.count + 1)
Decrement -> Model(model.count - 1)
Reset -> Model(0)
}
}
- Each branch returns a new
Modelwith the count changed - No mutation:
Model(model.count + 1)creates a new record
View
- In a browser application,
viewreturns Lustre'sElement(Msg)type:
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")]),
])
}
html.div(attrs, children)mirrors<div>...</div>event.on_click(Increment)attaches a click listener that sendsIncrementtoupdatehtml.textrenders a string as a text node- The return type
Element(Msg)guarantees that every event this tree can fire is a validMsg
Console Demo
let model = init()
let model = update(model, Increment)
let model = update(model, Increment)
let model = update(model, Decrement)
io.println(view(model))
- The demo runs without a browser to confirm the model-update-view logic in isolation
init()creates aModel(count: 0)- After two increments and one decrement,
model.countis1 view(model)returns"Count: +1 (click + or - to change)"
Running in the Browser
- To run in the browser:
- Add
lustretogleam.tomldependencies - Set
target = "javascript"ingleam.toml - Replace the console
viewwith an HTMLviewreturningElement(Msg) - Call
lustre.simple(init, update, view)inmain
- Add
pub fn main() {
let app = lustre.simple(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
}
"#app"is the CSS selector for the DOM element Lustre mounts into
Handling Forms
- A more complex model adds an input field and a list of items:
type Model {
Model(count: Int, input: String, items: List(String))
}
type Msg {
Increment
Decrement
Reset
InputChanged(String)
AddItem
RemoveItem(Int)
}
InputChanged(String)fires on every keystrokeupdatestores the new string inmodel.input
AddItemmovesmodel.inputintomodel.itemsand clears the input- The
viewrenders an<input>withevent.on_input(InputChanged)and anAddbutton withevent.on_click(AddItem) - This pattern extends to any form
- Bind each field to a
Msgvariant - Store it in the model
- Submit on a button click
- Bind each field to a
Connecting to an API
- Lustre effects run IO outside the pure
updatefunction:
fn fetch_todos() -> effect.Effect(Msg) {
effect.from(fn(dispatch) {
// make HTTP request, then:
dispatch(TodosLoaded(todos))
})
}
effect.fromwraps an IO action; thedispatchcallback sends aMsgback when the action completesupdatereturns#(Model, Effect(Msg))when effects are used- The runtime runs the effect after rendering
- This keeps the model and update function pure: side effects are explicitly separated
Testing
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)
}
- The
updatefunction is pure, so testing it requires no browser increment_testchains two increments and checksmodel.countdecrement_testconfirms decrement goes negativereset_testconfirmsResetalways returns count to 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.