Lustre v5.0.0 released!

Lustre’s v4 release was the largest so far, marking the first version to move away from React as the underlying runtime in favour of a custom implementation. In what is hopefully not going to become a tradition, this release includes an even bigger rewrite and a bunch of exciting new features!

This release also marks the expansion of Lustre’s team, with the addition of Yoshi. Her input and contributions for this release have been invaluable and many of the most impactful changes here are thanks to her hard work!

Performance improvements

A large part of this release has involved a complete rewrite of the runtime and virtual DOM. This has resulted in significant performance improvements over v4! Inspired by Evan’s original Elm benchmark, we created a benchmark app to compare Lustre’s performance against v4 and other frameworks. Let’s see how v5 stacks up against v4:

This release is nearly three times as fast as v4 in this benchmark app. The results are incredibly promising when compared to other frameworks too, with Lustre stacking up well against Elm v0.19.1 and React v19.1.0:

Both of these benchmarks were recorded on an M3 MacBook Air with 16GB of RAM running Safari.

Lustre is now able to hold its own when pit up against contemparies like React and Elm, and our production users should feel much more confident that Lustre is ready to scale with them as they grow.

Of course, all benchmarks should be taken with a grain of salt, and this one is no different. Importantly, each of the three benchmark apps are the “naive” implementations of TodoMVC with no use of optimisation techniques like memoization or Html.Lazy. This puts Lustre on even footing with the other frameworks but also means that these results don’t perfectly reflect the best performance possible in those other frameworks.

In the coming weeks we’ll release a more-comprehensive breakdown of the benchmark and its methodology, as well as begin work on a memo optimisation to catch up with the other frameworks!

Growing up

It’s been a little over a year since Lustre’s v4 release, which marked the point where Lustre stopped being a toy project and started being something I tried to take seriously. Since then Lustre has grown tremendously: the number of GitHub stars as increased by almost an order of magnitude, Lustre is one of the most popular non-core Gleam packages, content creators have made videos about it, and companies are betting their businesses on it!

With all that growth comes a fair bit of responsibility, and Lustre has a bit of an infamous reputation for lacking tests. This release makes a huge step in the right direction, with a suite of over 60 tests covering the core vdom and event handling functionality.

We also have a robust benchmark app that gives us a good idea of how we pit up against other frameworks, and helps make sure we’re not taking step backwards in performance.

The project definitely has a way to go and we’re still looking for a good way to test the runtime that isn’t just “open a bunch of the examples and see if they work,” but hopefully this work will signal to more folks that Lustre is worth their time and attention.

Better support for HTML forms

A lot of Lustre’s positioning as a framework is around making it easy for backend developers to build more-interactive frontend experiences. A common question from these folks is “how do I handle forms?” and typically the response has been that you shouldn’t need forms now that you control input state with your Model and handle effects like HTTP requests in your update function.

While that is still the case, we were leaving a lot of great native functionality on the table by eschewing forms entirely and made it harder for developers to write semantic HTML.

To better-support the use of native HTML forms, Lustre now includes a non-standard detail.formData property on submit events that contains a list of name-value pairs for each form field. This neatly matches the format expected by packages like formal and the built-in event.on_submit handler has already decoded this data for you:

fn view_login() -> Element(Msg) {
  let handle_submit = fn(form_data) {
    form.decoding({
      use username <- form.parameter
      use password <- form.parameter

      LoginData(username:, password:)
    })
    |> form.with_values(form_data)
    |> form.field("username", form.string |> form.and(form.must_not_be_empty))
    |> form.field("password", form.string |> form.and(form.must_be_string_longer_than(8)))
    |> form.finish
    |> UserSubmittedLoginForm
  }

  html.form([event.on_submit(handle_submit)], [
    ...
  ])
}

Hopefully this both eases the curve for newcomers and lets more-experienced Web developers get more out of the platform while using Lustre!

New component functionality

Continuing on from better support for HTML forms, Lustre’s components can now be configured to participate in native HTML form submission thanks support for form-associated custom elements. Lustre components are built directly on top of the Custom Elements API and so when the platform improves, Lustre does too! Form-associated custom elements allow Lustre components to be used as form fields, with their value being submitted as part of the form data.

To support these expanded configuration options, the lustre.component api has been updated to accept a list of options rather than just a dictionary of attributes to watch. Making a form-associated component is as simple as setting one option:

let app = lustre.component(init:, update:, view:, options: [
  component.form_associated(True),
])

To control the value submitted in forms, you now have access to the component.set_form_value and component.reset_form_value effects. Additionally, there are options to handle cases such as the browser autofilling or restoring your component’s value automatically.

Changes to event handlers

The event handler API has been updated to use the standard library’s newer gleam/dynamic/decode module and was the primary motivation for this major release. This marks a shift from imperative event handlers that receive the event object directly, to declarative handlers that provide just a decoder and leave the rest up to the runtime.

// Before:
event.on("mousemove", fn(event) {
  use x <- result.try(dynamic.field("offsetX", dynamic.int)(event))
  use y <- result.try(dynamic.field("offsetY", dynamic.int)(event))

  Ok(handle_mousemove(x, y))
})

// After:
event.on("mousemove", {
  use x <- decode.field("offsetX", decode.int)
  use y <- decode.field("offsetY", decode.int)

  decode.success(handle_mousemove(x, y))
})

The move to declarative event handlers means that prevent_default and stop_propagation are now modifiers that can be applied to an existing handlers. This also allows server components to prevent the default behaviour of an event or stop its propagation.

In addition, Lustre now has built-in support for event throttling and debouncing, allowing you to better control how often events are sent to your update function. This is a boon for handling events like scroll and mousemove that can fire very frequently but may not need to be handled at such a fine granularity. In particular, server components will now be able to reduce the frequency of messages sent over the wire, which is especially important for mobile devices and low bandwidth connections.

// A debounced event will only fire after it has "settled". That means if there
// is a burst of events, only the most recent event will be fired after the given
// delay:
//
//    original : --a-b-cd--e----------f--------
//   debounced : ---------------e----------f---
//
event.on_input(handle_input) |> event.debounce(500),

And the rest

Besides the headlines, v5.0.0 also brings a bunch of smaller improvements too. Server components can now communicate over Server-Sent Events (SSE) and HTTP long polling, emit events when the component during the connection lifecycle, and better handle low bandwidth connections.

The wrapper around fragments has been removed so once again calling element.fragment will not add extra markup to the DOM, and element.keyed has been removed in favour of a dedicated keyed module to prevent edge cases that could crop up in the old api.

Lifecycle effects for before_paint and after_paint have been added to allow you to better control when side effects are run compared to the browser’s paint cycle. This is especially useful for animations and transitions where you want to read the DOM and re-render before the browser paints the screen.


Lustre is still largely maintained by me – Hayleigh – with the support of a small number of contributors. To my existing sponsors on GitHub, thank you! Your support has fueled me with both motivation and caffeine to keep the project growing 💕.

If you’re interested in supporting Lustre, one of the best things you can do is build something with it and tell everyone about it!

If you or your company are using Lustre in production, please consider supporting the project financially over on GitHub sponsors.

Search Document