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:

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://cataas.com/cat")]),
          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:

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 with lustre.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 is element.Element(Msg). The type parameter Msg 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:

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. While we’re here, we’ll also add gleam_json so we can decode the response from the cat API:

$ 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 Model {
  Model(count: Int, cats: List(String))
}

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:

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
  ApiReturnedCat(Result(String, 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(..model, count: model.count - 1), effect.none())
    ApiReturnedCat(Ok(cat)) -> #(Model(..model, cats: [cat, ..model.cats]), effect.none())
    ApiReturnedCat(Error(_)) -> #(model, effect.none())
  }
}

fn get_cat() -> effect.Effect(Msg) {
  let decoder = dynamic.field("_id", dynamic.string)
  let expect = lustre_http.expect_json(decoder, ApiReturnedCat)

  lustre_http.get("https://cataas.com/cat?json=true", expect)
}

Note: The get_cat function returns an Effect that tells the runtime how to fetch a cat image. It’s important to know that the get_cat function doesn’t perform the request directly! This is why we need to add the ApiReturnedCat 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("https://cataas.com/cat/" <> cat)])
      }),
    ),
  ])
}

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:

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.

Search Document