Learning Lustre: Type-safe frontend development with gleam

I recently got hired for an internship! They assigned me to build the frontend for a credit score analysis application, the only problem was that I had no prior experience with frontend development.

I honestly didnt want to use javascript for that, both language and ecosystem felt too messy to me. I didnt want to leave behind the type safety and predictability I had when using gleam, so after some research, I finally decided to finally give Lustre 0 a try!

What is Lustre?

Lustre is a declarative, functional framework for building web applications with gleam. It focuses on simplicity by design, and it requires no use of macros or templates.

As mentioned on its documentation :

Modern frontend development is hard and complex. Some of that complexity is necessary, but a lot of it is accidental or comes from having far too many options. Lustre has the same design philosophy as Gleam: where possible, there should be only one way to do things!

The Model-View-Update architecture

Inspired by Elm and erlang, lustre uses message passing0 for managing state, a Lustre application consists of three main parts:

pub type Message

pub type Model

fn init(_props) {
todo as "build initial model"
}

fn view(model: Model) {
todo as "render your UI"
}

fn update(model: Model, message: Message) {
todo as "update current model"
}

Message

A message describes a state change in your application. They are defined by using a gleam custom type and usually follow the convention: SUBJECT VERB OBJECT.

pub type Message {
/// User updated email field
UserTypedEmail(email: String)
/// User updated password field
UserTypedPassword(password: String)

/// User sent login credentials to the Server
UserClickedSubmit

/// API validated User's credentials
ApiReturnedSession(result: Result(session.Session, rsvp.Error(String)))
}

Model

Your application state is global, not scoped to the current page. You can store page-specific state inside your Model if necessary, that's how I implemented it during my internship project.

Your Model can be defined in your project's root module, and store a page field that for the current page state.

// client.gleam

pub type Model {
Model(
/// Current user
session: session.Session,
/// Current route
route: route.Route,
/// Current Page model
page: page.Page,
/// Selected language
lang: lang.Language,
)
}

The modem package provides the functionality of intercepting navigation to internal links, and sending them to your update function through the provided handler.

You must setup its functionality during initialization.

pub fn init(opts: Init) -> #(Model, effect.Effect(Message)) {
let route = route.parse(opts.uri)
let #(page, effect) = page.init(route)

let init_modem = {
use uri <- modem.init()
let route = route.parse(uri)

// This message will be sent whenever a link is
// intercepted by the `modem` package, and needs to be
// handled properly by your app `update` function.
UserNavigatedTo(route)
}

// `init` functions in Lustre applications must
// provide the initial Model and an side effect to
// run after its done initializing.
let effect = effect.batch([effect, init_modem])
#(Model(route:, page:), effect)
}

Each page can implement its own view and update functions. This way, a page is responsible for its own state management and html rendering.

Both route and page fields from our Model are updated whenever the user navigates around the application.

pub fn update(model: Model, message: Message) {
case model, message {
Model(route: route.Login, page: page.Login(page), ..), LoginMessage(page_message) ->
handle_login_message(model, page, page_message)

Model(route: route.Dashboard, page: page.Dashboard(page), ..), DashboardMessage(page_message) ->
handle_dashboard_message(model, page, page_message)

_, _ -> todo
}
}

View

Your view function is pure, it means that the same Model will always render the same html.

Lustre provides a module for building the skeleton of your page, the coolest part is that its just regular gleam code, all you need to do is import the html module and access its functions.

Here I'm pattern matching on the session field from my application's Model, in order to decide which route this tag leads to.

pub fn view(model: Model) {
case session {
session.Authenticated(..) -> {
let attributes = [
route.href(route.Dashboard),
attribute.class("font-bold bg-primary text-primary-foreground"),
]

html.a(attributes, [
html.text("Dashboard"),
])
}

session.Guest -> {
let attributes = [
route.href(route.Login),
attribute.class("py-2 px-4 rounded-md hstack"),
attribute.class("font-bold bg-primary text-primary-foreground"),
]

html.a(attributes, [
icon.log_in([class("size-4")]),
html.text("Login"),
])
}

session.Pending(..) -> {
let attributes = [
attribute.class("flex gap-2 items-center"),
attribute.class("font-bold bg-primary text-primary-foreground"),
]

html.div(attributes, [
// Render spinner when waiting for Authentication.
html.span([attr.aria_busy(True), attr.data("spinner", "small")], []),
html.p([], [html.text("Loading")]),
])
}
}
}

Update

Your update function takes two arguments:

You can pattern match on its arguments to decide what to do next, and update your application state accordingly. Gleam allows pattern match on multiple values.

pub fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {
case model, message {
// NAVIGATION
model, UserNavigatedTo(route:) -> handle_navigation(model, route)

// LANGUAGE SELECTION
model, NavbarMessage(message: navbar.UserSelectedLanguage(lang:)) -> #(
Model(..model, lang:),
effect.none(),
)

// SESSION MANAGEMENT -----------------------------------------------------
//
// If the Server successfully authenticated the User,
// initialize its Session, and redirect them to the correct route.
Model(session: session.Pending(on_success:, ..), ..),
UserRestoredSession(result: Ok(session))
-> {
let route = on_success
let #(page, page_effect) = page.init(route)
let redirect = modem.push(route.path(route), option.None, option.None)

let effect = effect.batch([page_effect, redirect])
#(Model(..model, session: route:, page:), effect)
}

// If it fails, start the Session as a Guest and redirect
// the User accordingly, usually to the Login Page.
Model(session: session.Pending(on_failure:, ..), ..),
UserRestoredSession(result: Error(..))
-> {
let session = session.Guest
let route = on_failure

let #(page, page_effect) = page.init(route)
let redirect = modem.push(route.path(route), option.None, option.None)

let effect = effect.batch([page_effect, redirect])
#(Model(..model, session:, route:, page:), effect)
}
}
}

Since navigating around the application also produces a message, you can easily control what pages can be accessed by a given User.

fn handle_navigation(
model: Model,
route: route.Route,
) -> #(Model, Effect(Message)) {
// Do nothing if the route doesnt change
use <- bool.guard(model.route == route, #(model, effect.none()))
let protected = route.is_protected(route)

let route = case model.session, route {
// If the route require the User to be authenticated,
// redirect them to the Login page.
session.Guest, _ | session.Pending(..), _ if protected -> route.Login
// If the User is *already* authenticated but navigating to
// the Login page, redirect them to Dashboard instead.
session.Authenticated(..), route.Login -> route.Dashboard

_, _ -> route
}

let #(page, effect) = page.init(route)
#(Model(..model, route:, page:), effect)
}

Again, pattern matching is usually all your need to solve most of your problems. Gleam's design focuses on having only one way to do things. It helps keeping your projects simple, and most importantly, predictable.

Compiling the project

After everything is set, you can use lustre_dev_tools to compile your application, bundling all necessary CSS, JS and HTML.

gleam run -m lustre/dev build
# Compiled in 0.08s
# Running lustre/dev.main
# Creating JavaScript bundle...
✅ Bundle successfully built.
✅ HTML generated.
# Copying 6 assets...
✅ Assets copied.
✅ Build complete!

After compiling, you can serve them from your backend and a have a fully functional application. If you already loves how gleam works on the backend, I highly suggest using it on the frontend too! <3