01 Quickstart guide
Welcome to the Lustre quickstart guide! This document should get you up to speed with the core ideas that underpin every Lustre application as well as how to get something on the screen.
What is a SPA?
Lustre can be used to create HTML in many different contexts, but it is primarily designed to be used to build Single-Page Applications – or SPAs. SPAs are a type of Web application that render content primarily in the browser (rather than on the server) and, crucially, do not require a full page load when navigating between pages or loading new content.
To help build these kinds of applications, Lustre comes with an opinionated runtime. Some of Lustre’s core features include:
-
Declarative rendering: User interfaces are constructed using a declarative API that describes HTML as a function of your application’s state. This is in contrast to more traditional imperative approaches to direct DOM mutation like jQuery.
-
State management: If UIs are a function of state, then orchestrating state changes is crucial! Lustre provides a simple message-based state management system modelled after OTP gen_servers, Gleam’s actors, and the Elm Architecture.
-
Managed side effects: Managing asynchronous operations like HTTP requests and timers can be tricky when JavaScript is single-threaded. Lustre provides a runtime to manage these side effects and let them communicate with your application using the same messages as your update loop.
Your first Lustre program
To get started, let’s create a new Gleam application and add Lustre as a dependency.
gleam new app && cd app && gleam add lustre
By default, Gleam builds projects for the Erlang target unless told otherwise. We
can change this by adding a target
field to the gleam.toml
file generated in
the root of the project.
name = "app"
+ target = "javascript"
version = "1.0.0"
...
The simplest type of Lustre application is constructed with the element
function.
This produces an application that renders a static piece of content without the
typical update loop.
We can start by importing lustre
and lustre/element
and just rendering some
text:
import lustre
import lustre/element
pub fn main() {
let app = lustre.element(element.text("Hello, world!"))
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
Lustre has some official development tooling published in the
lustre_dev_tools
package. Most projects
will probably want to add those too!
Note: the lustre_dev_tools development server watches your filesystem for changes to your gleam code and can automatically reload the browser. For linux users this requires inotify-tools be installed. If you do not or cannot install this, the development server will still run but it will not watch your files for changes.
gleam add --dev lustre_dev_tools
It’s important to make sure the development tooling is added as a --dev
dependency. This ensures they’re never included in production builds of your app.
To start a development server, we can run:
gleam run -m lustre/dev start
The first time you run this command might take a little while, but subsequent runs should be much faster!
Note: Lustre uses esbuild under the hood, and attempts to download the right binary for your platform. If you’re not connected to the internet, on an unsupported platform, or don’t want Lustre to download the binary you can grab or build it yourself and place it in
build/.lustre/bin/esbuild
.
Once the server is up and running you should be able to visit http://localhost:1234 and be greeted with your “Hello, world!” message.
We mentioned Lustre has a declarative API for constructing HTML. Let’s see what that looks like by building something slightly more complex.
import lustre
import lustre/attribute
import lustre/element
import lustre/element/html
pub fn main() {
let app =
lustre.element(
html.div([], [
html.h1([], [element.text("Hello, world!")]),
html.figure([], [
html.img([attribute.src("https://cdn2.thecatapi.com/images/b7k.jpg")]),
html.figcaption([], [element.text("A cat!")])
])
])
)
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
Here we describe the structure of the HTML we want to render, and leave the busywork to Lustre’s runtime: that’s what makes it declarative!
“Where are the templates?” we hear you cry. Lustre doesn’t have a separate templating syntax like JSX or HEEx for a few reasons (lack of metaprogramming built into Gleam, for one). Some folks might find this a bit odd at first, but we encourage you to give it a try. Realising that your UI is just functions can be a bit of a lightbulb moment as you build more complex applications.
Adding interactivity
Rendering static HTML is great, but we said at the beginning Lustre was designed
primarily for building SPAs – and SPAs are interactive! To do that we’ll need
to move on from lustre.element
to the first of Lustre’s application constructors
that includes an update loop: lustre.simple
.
import gleam/int
import lustre
import lustre/element
import lustre/element/html
import lustre/event
pub fn main() {
let app = lustre.simple(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
There are three main building blocks to every interactive Lustre application:
-
A
Model
that represents your application’s state and aninit
function to create it. -
A
Msg
type that represents all the different ways the outside world can communicate with your application and anupdate
function that modifies your model in response to those messages. -
A
view
function that renders your model to HTML.
We’ll build a simple counter application to demonstrate these concepts. Our
model can be an Int
and our init
function will initialise it to 0
:
pub type Model = Int
fn init(_flags) -> Model {
0
}
Note: The
init
function always takes a single argument! These are the “flags” or start arguments you can pass in when your application is started withlustre.start
. For the time being, we can ignore them, but they’re useful for passing in configuration or other data when your application starts.
The main update loop in a Lustre application revolves around messages passed in from the outside world. For our counter application, we’ll have two messages to increment and decrement the counter:
pub type Msg {
Increment
Decrement
}
pub fn update(model: Model, msg: Msg) -> Model {
case msg {
Increment -> model + 1
Decrement -> model - 1
}
}
Each time a message is produced from an event listener, Lustre will call your
update
function with the current model and the incoming message. The result
will be the new application state that is then passed to the view
function:
pub fn view(model: Model) -> element.Element(Msg) {
let count = int.to_string(model)
html.div([], [
html.button([event.on_click(Increment)], [
element.text("+")
]),
element.text(count),
html.button([event.on_click(Decrement)], [
element.text("-")
])
])
}
The above snippet attaches two click event listeners that produce an Increment
or Decrement
message when clicked. The Lustre runtime is responsible for
attaching these event listeners and calling your update
function with the
resulting message.
Note: notice that the return type of
view
iselement.Element(Msg)
. The type parameterMsg
tells us the kinds of messages this element might produce from events: type safety to the rescue!
This forms the core of every Lustre application:
- A model produces some view.
- The view can produce messages in response to user interaction.
- Those messages are passed to the update function to produce a new model.
- … and the cycle continues.
Talking to the outside world
This “closed loop” of messages and updates works well if all we need is an interactive document, but many applications will also need to talk to the outside world – whether that’s fetching data from an API, setting up a WebSocket connection, or even just setting a timer.
Lustre manages these side effects through an abstraction called an Effect
. In
essence, effects are any functions that talk with the outside world and might
want to send messages back to your application. Lustre lets you write your own
effects, but for now we’ll use a community package called
lustre_http
to fetch a new cat image
every time the counter is incremented.
Because this is a separate package, make sure to add it to your project first.
$ gleam add lustre_http
Now we are introducing side effects, we need to graduate from lustre.simple
to
the more powerful lustre.application
constructor.
import gleam/dynamic
import gleam/int
import gleam/list
import lustre
import lustre/attribute
import lustre/effect
import lustre/element
import lustre/element/html
import lustre/event
import lustre_http
pub fn main() {
let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
If you edited your previous counter app, you’ll notice the program no longer
compiles. Specifically, the type of our init
and update
functions are wrong
for the new lustre.application
constructor!
In order to tell Lustre about what effects it should perform, these functions now
need to return a tuple of the new model and any effects. We can amend our init
function like so:
pub type Cat {
Cat(id: String, url: String)
}
pub type Model {
Model(count: Int, cats: List(Cat))
}
fn init(_flags) -> #(Model, effect.Effect(Msg)) {
#(Model(0, []), effect.none())
}
The effect.none
function is a way of saying “no effects” – we don’t need to do
anything when the application starts. We’ve also changed our Model
type from a
simple type alias to a Gleam record
that holds both the current count and a list of cat image URLs.
In our update
function, we want to fetch a new cat image every time the counter
is incremented. To do this we need two things:
- An
Effect
to describe the request the runtime should perform. - A variant of our
Msg
to handle the response.
The lustre_http
package has the effect side of things handled, so we just need
to modify our Msg
type to include a new variant for the response:
pub type Msg {
UserIncrementedCount
UserDecrementedCount
ApiReturnedCats(Result(List(Cat), lustre_http.HttpError))
}
Note: Concerned your message type is too verbose? Read our thoughts on why this is a good thing in our state management guide.
Finally, we can modify our update
function to also fetch a cat image when the
counter is incremented and handle the response:
pub fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) {
case msg {
UserIncrementedCount -> #(Model(..model, count: model.count + 1), get_cat())
UserDecrementedCount -> #(
Model(count: model.count - 1, cats: list.drop(model.cats, 1)),
effect.none(),
)
ApiReturnedCats(Ok(api_cats)) -> {
let assert [cat, ..] = api_cats
#(Model(..model, cats: [cat, ..model.cats]), effect.none())
}
ApiReturnedCats(Error(_)) -> #(model, effect.none())
}
}
fn get_cat() -> effect.Effect(Msg) {
let decoder =
dynamic.decode2(
Cat,
dynamic.field("id", dynamic.string),
dynamic.field("url", dynamic.string),
)
let expect = lustre_http.expect_json(dynamic.list(decoder), ApiReturnedCats)
lustre_http.get("https://api.thecatapi.com/v1/images/search", expect)
}
Note: The
get_cat
function returns anEffect
that tells the runtime how to fetch a cat image. It’s important to know that theget_cat
function doesn’t perform the request directly! This is why we need to add theApiReturnedCat
message variant: the runtime needs to know what to do with the response when it arrives.
This model of managed effects can feel cumbersome at first, but it comes with some benefits. Forcing side effects to produce a message means our message type naturally describes all the ways the world can communicate with our application; as an app grows being able to get this kind of overview is invaluable! It also means we can test our update loop in isolation from the runtime and side effects: we could write tests that verify a particular sequence of messages produces an expected model without needing to mock out HTTP requests or timers.
Before we forget, let’s also update our view
function to actually display the
cat images we’re fetching:
pub fn view(model: Model) -> element.Element(Msg) {
let count = int.to_string(model.count)
html.div([], [
html.button([event.on_click(UserIncrementedCount)], [
element.text("+")
]),
element.text(count),
html.button([event.on_click(UserDecrementedCount)], [
element.text("-")
]),
html.div(
[],
list.map(model.cats, fn(cat) {
html.img([
attribute.src(cat.url),
attribute.width(400),
attribute.height(400),
])
}),
),
])
}
Note: Depending on how fast the cat images download, and your browser window size and zoom level, you might notice that when you click the increment counter that the last cat image is duplicated before the new image loads. This is expected. To learn more about why this happens and how to prevent this behaviour, see rendering lists
Where to go from here
Believe it or not, you’ve already seen about 80% of what Lustre has to offer! From these core concepts, you can build rich interactive applications that are predictable and maintainable. Where to go from here depends on what you want to build, and how you like to learn:
-
There are a number of examples if the Lustre repository that gradually introduce more complex applications and ideas.
-
The rest of this guide also continues to teach Lustre’s high-level concepts and best-practices.
-
Of course, if you want to dive in and start making things straight away, the API documentation is always handy to keep open.
Getting help
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.
While our docs are still a work in progress, the official Elm guide is also a great resource for learning about the Model-View-Update architecture and the kinds of patterns that Lustre is built around.