| name | lustre-guide |
| description | Lustre framework patterns for Gleam UIs in client/ and examples/client/. Covers MVU architecture (init/update/view), application constructors (lustre.simple, lustre.application, lustre.component), message naming using Subject Verb Object (UserUpdatedX, BackendReturnedY), state management patterns (RemoteData, page-scoped state, EditState) to make invalid state unrepresentable, event handling (on_input, on_check, on_submit, on_keydown, custom event decoders), controlled vs uncontrolled inputs, side effects (effect.from, effect.batch, effect.before_paint, effect.after_paint), keyed list rendering, attributes vs properties, and pure functions. Use this whenever editing client.gleam, view/update/init functions, model types, or anything in the Lustre client. Also trigger on mentions of MVU, Effect, Msg, Model, view function, Lustre routing, or rsvp/modem/plinth packages. |
Lustre Framework Guide
Reference for building Lustre applications in Gleam. Based on the official Lustre docs.
Architecture (Model-View-Update)
Lustre follows the Elm Architecture — a unidirectional data flow pattern:
+--------+
| |
| update |
| |
+--------+
^ |
| |
Msg | | #(Model, Effect(Msg))
| |
| v
+------+ +------------------------+
| | #(Model, Effect(Msg)) | |
| init |------------------------>| Lustre Runtime |
| | | |
+------+ +------------------------+
^ |
| |
Msg | | Model
| |
| v
+--------+
| |
| view |
| |
+--------+
- Model — the entire state of the application at a point in time.
- View — a pure function of the model: if the model doesn't change, the UI doesn't change.
- Messages — events from the outside world (user interaction, HTTP responses, etc.) sent to the update function.
- Update — receives model + message, returns new model (and optional effects).
- The runtime re-renders the view with the new model.
Application Constructors
// Static element — no state, no events
lustre.element(element)
// Simple app — state + events, no managed effects
lustre.simple(init, update, view)
// init: fn(flags) -> model
// update: fn(model, msg) -> model
// view: fn(model) -> Element(msg)
// Full app — state + events + managed effects
lustre.application(init, update, view)
// init: fn(flags) -> #(model, Effect(msg))
// update: fn(model, msg) -> #(model, Effect(msg))
// view: fn(model) -> Element(msg)
// Component — registered as a Custom Element
lustre.component(init, update, view, on_attribute_change)
Starting an App
pub fn main() {
let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
State Management
The best model is not always a record
Use custom type variants to model distinct application states:
type Model {
LoggedIn(LoggedInModel)
Public(PublicModel)
}
This makes impossible states impossible — you can write separate update/view functions per variant.
A type alias (type Model = Dict(String, Post)) is also fine when appropriate.
Make invalid state unrepresentable
Three patterns to keep the model honest. Each removes a category of "this combination shouldn't be possible but the type allows it."
Collapse loading/data/error triples. Three independent fields representing one logical state machine let bogus combinations exist (loading=True ∧ data=Some(...), loading=False ∧ data=None ∧ error=None, etc.).
// Bad — three fields, many combinations, only some are valid
Model(loading: Bool, data: Option(a), error: Option(String))
// Good — one field, three variants, every value is valid
type RemoteData(a) {
Loading
Loaded(a)
Failed(String)
}
Push per-route state into the route variant. Top-level fields that are only meaningful on some routes leak across navigations and force defensive case checks in views.
// Bad — form lives at the top level even on routes that never use it
type Model {
Model(route: Route, activities: List(Activity), form: Form(F), ...)
}
// Good — each route carries exactly the state it needs
type Page {
ListPage(state: RemoteData(List(Activity)))
NewPage(form: Form(F), submit_error: Option(String))
DetailPage(id: String, state: RemoteData(Activity))
}
type Model {
Model(page: Page, translator: Translator)
}
A useful consequence: API result handlers must pattern-match on model.page, which naturally drops stale responses if the user has navigated away while a fetch was in flight.
Bind correlated fields in one constructor. Fields that must agree (e.g. an entity and a form prefilled from it) belong in the same variant, not as siblings that can drift apart.
// Bad — form and selected_activity can disagree
Model(selected_activity: Option(Activity), form: Form(F), ...)
// Good — both are populated together; one without the other is unrepresentable
type EditState {
EditLoading
EditReady(activity: Activity, form: Form(F), submit_error: Option(String))
EditLoadFailed(String)
}
Messages not actions
Name messages using Subject Verb Object to describe who sent them and what happened:
// Good — clear origin and intent
type Msg {
UserUpdatedPassword(String)
UserRequestedPasswordReset
BackendResetPassword(Result(Nil, String))
}
// Bad — action-style, unclear origin
type Msg {
SetPassword(String)
ResetPassword
PasswordReset(Result(Nil, String))
}
Never recursively call your update function with different messages. Use plain functions to compose behavior.
View functions not components
Prefer stateless view functions (fn(...) -> Element(Msg)) over stateful components. Components are for when you genuinely need encapsulated state + Custom Element features (shadow DOM, slots, attributes). View functions are easier to test, refactor, and reason about.
Event Handling
Common lustre/event helpers
| Function | Decodes | Use for |
|---|
event.on(name, decoder) | Custom decoder on event object | Any event, especially custom ones |
event.on_click(msg) | Nothing (fixed msg) | Standard click |
event.on_input(fn(String) -> msg) | event.target.value as String | Text inputs, textareas, selects |
event.on_check(fn(Bool) -> msg) | event.target.checked as Bool | Checkboxes |
event.on_submit(fn(FormData) -> msg) | Form name/value pairs | Form submission (auto-prevents default) |
event.on_keydown(fn(String) -> msg) | Key name as String | Keyboard handling |
event.prevent_default(attr) | — | Wrap any event attr to cancel default |
event.stop_propagation(attr) | — | Wrap any event attr to stop bubbling |
Custom event decoders
Use event.on with gleam/dynamic/decode for custom events:
event.on("scoutChange", {
use value <- decode.subfield(["detail", "value"], decode.int)
decode.success(TabChanged(value))
})
Controlled vs Uncontrolled Inputs
Controlled — model is single source of truth
// View
html.input([
attribute.value(model.name),
event.on_input(UserUpdatedName),
])
// Update
UserUpdatedName(value) -> #(Model(..model, name: value), effect.none())
Use when: validating on every keystroke, formatting as user types, restricting input.
Uncontrolled — browser manages state
html.form([event.on_submit(UserSubmittedForm)], [
html.input([
attribute.type_("text"),
attribute.name("username"),
]),
])
Use when: many form fields, only need values on submit, leveraging native validation, server components.
To clear an uncontrolled input: render it in a keyed.div or keyed.fragment and change the key.
Side Effects
Effects tell the runtime what side effects to perform. Your init, update, and view functions must remain pure.
Basics
// No effect
effect.none()
// Run multiple effects at once
effect.batch([effect_a, effect_b])
Custom effects with effect.from
fn read(key: String, to_msg: fn(Result(String, Nil)) -> msg) -> Effect(msg) {
effect.from(fn(dispatch) {
do_read(key)
|> to_msg
|> dispatch
})
}
Effects without dispatch (fire-and-forget):
fn write(key: String, value: String) -> Effect(msg) {
effect.from(fn(_) { do_write(key, value) })
}
Effects can dispatch multiple messages over time (timers, WebSockets, event listeners).
DOM-timing effects
effect.before_paint — runs after view but before browser paints
effect.after_paint — runs after browser paints
Use these when you need to measure or manipulate DOM elements.
Community packages
| Package | Purpose |
|---|
rsvp | HTTP requests |
modem | Navigation and routing |
plinth | Node.js and browser platform API bindings |
Rendering Lists
Use element.keyed (or keyed.div, keyed.ul, etc.) for lists to help Lustre accurately track which items changed, moved, or were removed:
keyed.ul([], list.map(model.items, fn(item) {
#(item.id, html.li([], [html.text(item.name)]))
}))
Without keys, reordering or prepending to lists can cause visual glitches (e.g. stale images appearing briefly).
Attributes vs Properties
attribute.attribute(name, value) — sets an HTML attribute (string only, visible in DOM, serialized for SSR)
attribute.property(name, value) — sets a JS property directly (any JSON type, NOT serialized for SSR)
Use attribute for simple strings. Use property for arrays, objects, or booleans the element reads as JS properties. For SVG elements, always use attributes (properties are typically read-only).
Pure Functions
Lustre assumes init, update, and view are pure — same input always produces same output, no side effects. Breaking this causes unexpected behavior. Use managed effects (Effect type) for anything that touches the outside world.
Official Guides
Hints
Reference
Official Examples
01-basics
02-inputs
03-effects
04-applications
| Example | Description |
|---|
| 01-routing | Routing and navigation between pages |
| 04-hydration | Client-side hydration of server-rendered apps |
05-components
06-server-components