Lustre for Elm developers
Lustre has been directly inspired by Elm and shares many of the same architectural features, particularly the Model-View-Update (MVU) pattern. This guide is for Elm developers who are new to Lustre and want to get up to speed quickly.
How do I…?
Setup a new project
In Elm, all you need to get started is to install the elm
binary.
Running elm make
against an Elm file will transpile your code to either JavaScript,
or HTML with the JavaScript output inlined. Most people build their own toolchain
to support build-on-save and hot-reload, using tools like Vite or Webpack with
appropriate plugins. A simple hello world looks like this:
module Main exposing (main)
import Html
main =
Html.text "Hello, world"
In Lustre, you need to install the lustre
package with gleam add lustre
.
Most Lustre projects will also add the dev tools with gleam add --dev lustre_dev_tools
.
A simple hello world looks like this:
import lustre
import lustre/element/html
pub fn main() {
let app = lustre.element(html.h1([], [html.text("Hello, world")]))
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
Render some HTML
In Elm, you call functions in the elm/html
package to render HTML elements.
The Html
module contains functions for most standard HTML tags; these functions
take parameters including a list of attributes from Html.Attributes
or events
from Html.Events
, as well as a list of child elements:
Html.button
[ Html.Attributes.class "primary"
, Html.Events.onClick ButtonClicked
]
[ Html.text "Click me" ]
In Lustre, HTML is rendered similarly by calling functions. Functions in
lustre/element/html
represent HTML tags, and most accept a list of lustre/attribute
or lustre/event
values, as well as a list of child elements:
html.button([attribute.class("primary"), event.on_click(ButtonClicked)], [
html.text("Click me")
])
Render some text
In Elm, text is rendered by passing a String
to the Html.text
function:
Html.span [] [ Html.text <| "Hello, " ++ name ]
In Lustre, text is rendered by passing a String
to the html.text
function:
html.span([], [
html.text("Hello, " <> name),
])
Manage state
In Elm, all state is stored in a single Model
type and updates happen through
a central update
function:
type alias Model = Int
init : Model
init = 0
type Msg
= Incr
| Decr
update : Msg -> Model -> Model
update msg model =
case msg of
Incr ->
model + 1
Decr ->
model - 1
In Lustre, state management works almost identically to Elm with a central
Model
type and an update
function:
type Model =
Int
fn init(_) -> Model {
0
}
type Msg {
Incr
Decr
}
fn update(model: Model, msg: Msg) -> Model {
case msg {
Incr -> model + 1
Decr -> model - 1
}
}
Handling side effects
In Elm, side effects like HTTP requests are handled with commands. The
update
function returns both a new model and a command to perform:
type Msg
= ApiReturnedBookResponse (Result Http.Error String)
getBook : Cmd Msg
getBook =
Http.get
{ url = "https://elm-lang.org/assets/public-opinion.txt"
, expect = Http.expectString ApiReturnedBookResponse
}
type alias Model = { bookResponse : Result Http.Error String }
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ApiReturnedBookResponse response ->
( { model | bookResponse = response }, Cmd.none )
In Lustre, the approach is similar using the Effect
type. The recommendation
is to use the rsvp
package for HTTP requests:
type Msg {
ApiReturnedBookResponse(Result(String, rsvp.Error))
}
fn get_book() -> Effect(Msg) {
rsvp.get(
"https://elm-lang.org/assets/public-opinion.txt",
rsvp.expect_text(ApiReturnedBookResponse)
)
}
type Model {
Model(book_response: Result(String, rsvp.Error))
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
ApiReturnedBookResponse(response) -> #(
Model(..model, book_response: response),
effect.none()
)
}
}
Create a component
In Elm, there’s no built-in concept of components. Instead, you create functions that return HTML elements:
viewButton : String -> msg -> Html msg
viewButton label msg =
Html.button [ Html.Events.onClick msg ] [ Html.text label ]
-- In your view
view model =
Html.div []
[ viewButton "Increment" Increment
, viewButton "Decrement" Decrement
]
In Lustre, the primary approach is similar with view functions, but Lustre also supports stateful components:
// Simple view function (like Elm)
fn view_button(label: String, msg: msg) -> Element(msg) {
html.button([event.on_click(msg)], [html.text(label)])
}
// In your view
fn view(model: Model) -> Element(Msg) {
html.div([], [
view_button("Increment", Incr),
view_button("Decrement", Decr)
])
}
// Lustre also supports stateful components with their own MVU cycle
pub fn counter_component() -> App(Nil, CounterModel, CounterMsg) {
lustre.component(counter_init, counter_update, counter_view, [])
}
Work with lists
In Elm, you typically use List.map
to render lists of items:
view : Model -> Html Msg
view model =
Html.ul []
(List.map viewItem model.items)
viewItem : String -> Html Msg
viewItem item =
Html.li [] [ Html.text item ]
In Lustre, it works the same way using list.map
:
fn view(model: Model) -> Element(Msg) {
html.ul([],
list.map(model.items, view_item)
)
}
fn view_item(item: String) -> Element(Msg) {
html.li([], [html.text(item)])
}
For better performance with large lists, you can use keyed.ul
to provide a
unique key for each item:
fn view(model: Model) -> Element(Msg) {
keyed.ul([],
list.map(model.items, fn(item) {
#(item.id, html.li([], [html.text(item.text)]))
})
)
}
Handle form inputs
In Elm, you typically use event handlers like onInput
to capture form changes:
type Msg = UpdateName String
view : Model -> Html Msg
view model =
Html.input
[ Html.Attributes.value model.name
, Html.Events.onInput UpdateName
]
[]
In Lustre, this works similarly:
type Msg {
UpdateName(String)
}
fn view(model: Model) -> Element(Msg) {
html.input([
attribute.value(model.name),
event.on_input(UpdateName)
], [])
}
Differences to be aware of
-
Effect handling - While both Elm and Lustre use a similar approach, Lustre’s effect system provides more flexibility with
before_paint
andafter_paint
effects. -
Components - Lustre has first-class support for stateful components, which Elm doesn’t provide.
-
Syntax - Gleam’s syntax is more similar to Rust or OCaml than Elm’s Haskell-inspired syntax.
-
JavaScript interop - Lustre provides easier JavaScript interop through Gleam’s FFI system compared to Elm’s more restricted ports.
Where to go next
To walk through setting up a new Lustre project and building your first app, check out the quickstart guide.
If you prefer to learn by example, we have a collection of examples that show off specific features and patterns in Lustre. You can find them in the examples directory.
If you’re having trouble with Lustre or not sure what the right way to do something is, the best place to get help is the Gleam Discord server. You could also open an issue on the Lustre GitHub repository.