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 ecossystem 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 a try!
What is Lustre?
Lustre is a declarative, functional framework for building web applications with gleam. It focus 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 passing for managing state, a lustre application consists of three main parts:
Model: Your application state. It will be passed to your view function in order to determine how the UI will look like.
View: Render your html elements. User interactions and external events will produces messages that must be handled by your update function.
Update: Updates your application state. You can pattern match on the messages received by the UI and update the Model.
pub type Model
pub type Msg
fn init(_props) {
todo as "build initial model"
}
fn view(model: Model) {
todo as "render your UI"
}
fn update(model: Model, msg: Msg) {
todo as "update current model"
}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(Msg)) {
let route = route.parse(opts.uri)
let page = page.init(route)
let effect = {
use uri <- modem.init()
// 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.parse(uri))
}
// `init` functions in Lustre applications must
// provide the initial Model and an side effect to
// run after its done initializing.
#(Model(route:, page:), effect)
}Both route and page fields from our Model are updated whenever the user navigates around the application.
Each page can implement its own view and update functions. This way, a page is responsible for its own state management and html rendering.
pub fn update(model: Model, msg: Msg) {
case model, msg {
Model(route: route.Login, page: page.Login(page), ..), LoginMsg(page_msg) ->
handle_login_msg(model, page, page_msg)
Model(route: route.Dashboard, page: page.Dashboard(page), ..), DashboardMsg(page_msg) ->
handle_dashboard_msg(model, page, page_msg)
_, _ -> 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 is import the html module and access its functions.
Here im pattern matching on the session field from my application's Model, in order to decide which route this <a> tag will lead to.
case session {
session.Authenticated(..) -> {
let attributes = [
route.href(route.Dashboard),
class("font-bold bg-primary text-primary-foreground"),
]
html.a(attributes, [
html.text("Dashboard"),
])
}
session.Guest -> {
let attributes = [
route.href(route.Login),
class("py-2 px-4 rounded-md hstack"),
class("font-bold bg-primary text-primary-foreground"),
]
html.a(attributes, [
icon.log_in([class("size-4")]),
html.text("Login"),
])
}
session.Pending(..) -> {
let attributes = [
class("flex gap-2 items-center"),
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:
Your current application Model.
The message being received.
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, msg: Msg) -> #(Model, Effect(Msg)) {
case model, msg {
// NAVIGATION
model, UserNavigatedTo(route:) -> handle_navigation(model, route)
// LANGUAGE SELECTION
model, NavbarMsg(msg: 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))
-> #(
Model(..model, session:, route: on_success, page: page.init(on_success)),
modem.push(route.path(on_success), option.None, option.None),
)
// 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 model = Model(..model, route:, page: page.init(route), session:)
#(model, modem.push(route.path(route), option.None, option.None))
}
}
} Since navigating around the application also produces a message, your can easily control what pages can be accessed by a given User.
fn handle_navigation(
model: Model,
route: route.Route,
) -> #(Model, Effect(Msg)) {
// Do nothing 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 = page.init(route)
#(Model(..model, route:, page:), effect.none())
}Again, pattern matching is usually all your need to solve most of your problems. Gleam's design focus 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.
After compiling, you can serve them from your backend and a have a fully functional application.