Lustre Documentation

repository·main·Indexed 25 days ago

https://github.com/lustre-labs/lustre

A declarative, functional frontend framework for Gleam inspired by Elm and Erlang/OTP. Lustre enables the creation of HTML templates, single-page applications, Web Components, and real-time server components using a Model-View-Update (MVU) architecture. It supports universal components across different environments, declarative event handlers, and native HTML form integration.

Tokens
27.9K
Snippets
70
Records
126
Agent score
81%

What's inside Lustre

  1. Use uncontrolled inputs for simple forms

    main

    Uncontrolled inputs rely on the browser to manage their internal state. Your application does not track every keystroke; instead, you typically read the values only when a form is submitted (e.g., via an on_submit handler).

    Use uncontrolled inputs when you want to:

    • Reduce boilerplate by not managing state for every single field.
    • Only capture values upon form submission.
    • Leverage native browser form validation attributes.
    • Use server components (since uncontrolled inputs reduce the number of messages sent between client and server).
    html.form(
      [event.on_submit(UserSubmittedForm)],
      [
        html.input([
          attribute.type_("text"),
          attribute.name("username"),
          // Optional default value
          attribute.default_value(default_value),
          // But no on_input handler!
        ])
      ]
    )
  2. Prefer view functions over stateful components

    main

    In Lustre, a component is a stateful, nested Model-View-Update application. While available, they should not be the default. Instead, use view functions: stateless functions that return Elements.

    Why prefer view functions?

    • Intentional State: View functions only take arguments, forcing you to be deliberate about what state is actually needed.
    • Better Organization: Keeps code grouped by what it does rather than what it is, following idiomatic Gleam/Elm patterns.
    • Easier Testing: Plain view functions and data transformation functions are much easier to test with standard Gleam tools than encapsulated components.
    • Simpler Refactoring: Since state is managed higher up the tree rather than being tightly coupled inside a component, moving UI elements around is less painful.
    • Less Boilerplate: Components require defining an init, update, view, Model, and Message type, which adds significant overhead for simple UI tasks.
  3. Implement Hydration in Lustre

    main

    Lustre does not have a built-in hydration mechanism, but you can implement it by ensuring the server-rendered HTML and the client-side initial model are synchronized.

    The Pattern:

    1. Server-side: Render the application's view using the initial model. Serialize that initial model into a JSON string and embed it in the HTML (e.g., inside a <script> tag with a specific ID).
    2. Client-side: Use a browser API (via a package like plinth) to read the JSON from the script tag, decode it into the initial model, and pass it as flags to lustre.start.
    // 1. Server-side: Embedding the model in HTML
    // <script type="application/json" id="model">5</script>
    
    // 2. Client-side: Reading flags and starting the app
    import gleam/json
    import gleam/result
    import lustre
    import plinth/browser/document
    import plinth/browser/element
    
    pub fn main() {
      let json_text = 
        document.query_selector("#model")
        |> result.map(element.inner_text)
    
      let flags = 
        case json.parse(json_text, decode.int) {
          Ok(count) -> count
          Error(_) -> 0
        }
    
      let app = lustre.application(init, update, view)
      let assert Ok(_) = lustre.start(app, "#app", flags)
    }
  4. Manage server component lifecycle with on_connect and on_disconnect

    main

    In the context of server components, the component.on_connect and component.on_disconnect options map to client registration and deregistration with the runtime.

    Specifically, these options correspond to:

    • server_component.register_subject or register_callback (for connection)
    • The equivalent deregistration API (for disconnection)

    Handling these messages allows you to optimize workloads, such as ensuring a component does not perform unnecessary work when no clients are connected, or managing resource intensity when many clients are connected simultaneously.

  5. Fetch data using the Effect system

    main

    In Lustre, side effects like data fetching are handled via the Effect system. Unlike LiveView's handle_info, Lustre's init and update functions can return both a new state and an Effect to be executed.

    • init: Can return a tuple #(Model, Effect(Message)) to trigger an initial fetch.
    • update: Can return a tuple #(Model, Effect(Message)) to trigger a fetch after a specific message is processed.
    • Effect: An abstraction that encapsulates the side effect and returns a Message upon completion.
  6. What are universal components in Lustre?

    main

    Lustre supports universal components, which are components designed to run across different environments. Because they are written with Gleam's multiple targets in mind, you can write a component once and use it in several ways:

    • Inside an existing Lustre application: As a standard component.
    • As a standalone Web Component: Exported for use in other web environments.
    • On the server: Running with a minimal runtime for patching the DOM (similar to Phoenix LiveView).

    While Lustre allows for encapsulated stateful components, the library's philosophy encourages using simple functions for views whenever possible.

  7. Handle side effects with Effects

    main

    Side effects (like HTTP requests) are handled using the Effect type. The update function returns a tuple containing the new model and an Effect(Message).

    It is recommended to use the rsvp package for HTTP requests. The update function signature for handling effects is fn(Model, Message) -> #(Model, Effect(Message)).

    type Message {
      ApiReturnedBookResponse(Result(String, rsvp.Error))
    }
    
    fn get_book() -> Effect(Message) {
      rsvp.get(
        "https://elm-lang.org/assets/public-opinion.txt",
        rsvp.expect_text(ApiReturnedBookResponse)
      )
    }
    
    type Model {
      Model(book_response: Result(String, rsvp.Error))
    }
    
    fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {
      case message {
        ApiReturnedBookResponse(response) -> #(
          Model(..model, book_response: response),
          effect.none()
        )
      }
    }
  8. Manage state using the MVU pattern

    main

    Lustre follows the Model-View-Update (MVU) pattern. You define a Model type for state, a Message type for actions, and an update function that transitions the model based on messages.

    type Model =
      Int
    
    fn init(_) -> Model {
      0
    }
    
    type Message {
      Incr
      Decr
    }
    
    fn update(model: Model, message: Message) -> Model {
      case message {
        Incr -> model + 1
        Decr -> model - 1
      }
    }
  9. Use controlled inputs for fine-grained control

    main

    Controlled inputs are fully managed by your Lustre application state. The input's value attribute is explicitly set from your model, and an event handler like on_input or on_change updates your model when the user types. This creates a cycle where the DOM is a direct reflection of your application state.

    Use controlled inputs when you need to:

    • Validate input on every keystroke.
    • Format inputs as the user types.
    • Conditionally disable submission based on validity.
    • Restrict input length or content.
    html.input([
      // The value comes from your model
      attribute.value(model),
      // Changes update your model via a message
      event.on_input(UserUpdatedName),
      // Other attributes...
    ])
  10. Understand the Model-View-Update (MVU) architecture

    main

    Lustre applications are built using the Model-View-Update (MVU) architecture, which implements a unidirectional data flow:

    • Model: A single, immutable data structure that describes the entire state of your application at a given point in time.
    • View: A pure function of the model. If the model does not change, the UI does not change.
    • Update: A function that receives messages (events from the outside world like user interactions or HTTP responses) and constructs a new model.

    This approach provides a single source of truth, declarative state updates, and pure state transitions that are easy to test.

                                           +--------+
                                           |        |
                                           | update |
                                           |
                                           +--------+
                                             ^    |
                                             |    |
                                     Message |    | Model
                                             |    |
                                             |    v
    +------+                         +------------------------+
    |      |          Model          |                        |
    | init |------------------------>|     Lustre Runtime     |
    |      |                         |                        |
    +------+                         +------------------------+
                                             ^    |
                                             |    |
                                     Message |    | Model
                                             |    |
                                             |    v
                                           +--------+
                                           |        |
                                           |  view  |
                                           |
                                           +--------+
  11. How Lustre applications work: The Model-Message-View pattern

    main

    Lustre applications are built using a message-based state management system (similar to the Elm Architecture). Every interactive application is composed of three core building blocks:

    1. Model: A type representing the entire state of your application. An init function is used to construct the initial model.
    2. Message: A type representing all possible ways the outside world (e.g., user interactions, API responses) can communicate with your application. An update function receives these messages and returns a new Model.
    3. View: A function that takes the current Model and returns an Element(Message). This function is called whenever the model changes to re-render the UI.

    The Lifecycle Loop: Model $\rightarrow$ view $\rightarrow$ Element(Message) $\rightarrow$ (User Interaction) $\rightarrow$ Message $\rightarrow$ update $\rightarrow$ New Model $\rightarrow$ (Repeat)

    import gleam/int
    import lustre
    import lustre/element.{type Element}
    import lustre/element/html
    import lustre/event
    
    // 1. The Model
    type Model = Int
    
    // 2. The Messages
    type Message {
      UserClickedIncrement
      UserClickedDecrement
    }
    
    // The init function
    fn init(_args) -> Model {
      0
    }
    
    // The update function
    fn update(model: Model, message: Message) -> Model {
      case message {
        UserClickedIncrement -> model + 1
        UserClickedDecrement -> model - 1
      }
    }
    
    // 3. The View
    fn view(model: Model) -> Element(Message) {
      let count = int.to_string(model)
    
      html.div([], [
        html.button([event.on_click(UserClickedIncrement)], [html.text("+")]), 
        html.p([], [html.text(count)]),
        html.button([event.on_click(UserClickedDecrement)], [html.text("-")])
      ])
    }
    
    pub fn main() {
      // Use lustre.simple to tie the blocks together
      let app = lustre.simple(init, update, view)
      let assert Ok(_) = lustre.start(app, "#app", Nil)
      Nil
    }