Claude
Skills
Sign in
Back

gleam-lustre-development

Included with Lifetime
$97 forever

Guides Claude through idiomatic Lustre frontend development. Use when building SPAs, UI components, or interactive applications. Based on lustre_ui patterns from the official Lustre team.

Design

What this skill does


# Gleam Lustre Development Skill

This skill guides Claude Code through **idiomatic Lustre development** following patterns from the official `lustre_ui` library.

## Primary Sources

1. **[Lustre Documentation](https://hexdocs.pm/lustre/)** - Official docs
2. **[Lustre UI](https://github.com/lustre-labs/ui)** - Official component library (reference implementation)
3. **[Lustre Examples](https://github.com/lustre-labs/lustre/tree/main/examples)** - Official examples

## Core Architecture: Model-Update-View

Every Lustre application follows the Elm Architecture:

```gleam
import lustre
import lustre/effect.{type Effect}
import lustre/element.{type Element}

// TYPES -----------------------------------------------------------------------

type Model {
  Model(
    count: Int,
    // ... state fields
  )
}

type Msg {
  UserClickedIncrement
  UserClickedDecrement
  ApiReturnedData(Result(Data, Error))
}

// MAIN ------------------------------------------------------------------------

pub fn main() {
  let app = lustre.application(init, update, view)
  let assert Ok(_) = lustre.start(app, "#app", Nil)
  Nil
}

// INIT ------------------------------------------------------------------------

fn init(_flags) -> #(Model, Effect(Msg)) {
  let model = Model(count: 0)
  #(model, effect.none())
}

// UPDATE ----------------------------------------------------------------------

fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
    UserClickedIncrement -> #(Model(..model, count: model.count + 1), effect.none())
    UserClickedDecrement -> #(Model(..model, count: model.count - 1), effect.none())
    ApiReturnedData(Ok(data)) -> #(Model(..model, data: data), effect.none())
    ApiReturnedData(Error(_)) -> #(model, effect.none())
  }
}

// VIEW ------------------------------------------------------------------------

fn view(model: Model) -> Element(Msg) {
  html.div([], [
    html.button([event.on_click(UserClickedDecrement)], [html.text("-")]),
    html.p([], [html.text(int.to_string(model.count))]),
    html.button([event.on_click(UserClickedIncrement)], [html.text("+")]),
  ])
}
```

### Application Levels

```gleam
// Static HTML only (no interactivity)
lustre.element(html.div([], [html.text("Hello")]))

// Interactive without effects (init/update return Model only)
lustre.simple(init, update, view)

// Full application with effects (init/update return #(Model, Effect))
lustre.application(init, update, view)

// Registrable Web Component
lustre.component(init:, update:, view:, options: [...])
```

## MANDATORY: Message Naming Convention

**Messages MUST use Subject-Verb-Object naming that describes WHAT HAPPENED, not what to do:**

```gleam
// ✅ CORRECT: Describes what happened
type Msg {
  UserClickedSubmit
  UserTypedInField(value: String)
  UserPressedEnter
  UserSelectedOption(id: String)
  UserToggledCheckbox(checked: Bool)
  ApiReturnedUsers(Result(List(User), Error))
  ApiReturnedError(Error)
  ParentSetValue(value: String)
  ParentToggledOpen
  TimerFired
  WindowResized(width: Int, height: Int)
}

// ❌ WRONG: Imperative/command style
type Msg {
  Submit           // What does this mean?
  SetValue(String) // Command, not event
  Toggle           // Too vague
  LoadUsers        // Command, not event
  UpdateField      // Command, not event
}
```

**Prefixes by source:**
- `User...` - User interactions (clicks, typing, etc.)
- `Api...` - HTTP/API responses
- `Parent...` - Props from parent component
- `Timer...` / `Window...` / `Dom...` - Browser events
- `Child...` - Events from child components

## Controlled vs Uncontrolled Props

For components that can have state managed by parent OR internally:

```gleam
/// A prop that can be controlled by the parent or managed internally.
pub type Prop(a) {
  Prop(
    value: a,        // Current value
    controlled: Bool, // Is parent controlling this?
    touched: Bool,    // Has user interacted?
  )
}

pub fn new(value: a) -> Prop(a) {
  Prop(value: value, controlled: False, touched: False)
}

/// Set default value (only if not controlled and not touched)
pub fn default(prop: Prop(a), value: a) -> Prop(a) {
  case prop.controlled || prop.touched {
    True -> prop
    False -> Prop(..prop, value: value)
  }
}

/// Control from parent (always updates)
pub fn control(prop: Prop(a), value: a) -> Prop(a) {
  Prop(..prop, value: value, controlled: True)
}

/// User touched (only updates if not controlled)
pub fn touch(prop: Prop(a), value: a) -> Prop(a) {
  case prop.controlled {
    True -> prop
    False -> Prop(..prop, value: value, touched: True)
  }
}
```

**Usage in update:**

```gleam
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
  case msg {
    ParentSetDefaultValue(value) ->
      // Only apply if not controlled and not touched by user
      case model.open.controlled || model.open.touched {
        True -> #(model, effect.none())
        False -> {
          let open = Prop(..model.open, value: value)
          #(Model(..model, open: open), effect.none())
        }
      }
      
    ParentSetValue(value) -> {
      // Controlled: always update
      let open = Prop(..model.open, value: value, controlled: True)
      #(Model(..model, open: open), effect.none())
    }
    
    UserToggledOpen ->
      case model.open.controlled {
        // Controlled: emit event, don't update locally
        True -> #(model, emit_change(!model.open.value))
        // Uncontrolled: update locally AND emit event
        False -> {
          let open = Prop(..model.open, value: !model.open.value, touched: True)
          #(Model(..model, open: open), emit_change(!model.open.value))
        }
      }
  }
}
```

## Web Components (Registrable Components)

For reusable components that need their own state:

```gleam
import lustre
import lustre/component

pub const tag: String = "my-component"

pub fn register() -> Result(Nil, lustre.Error) {
  let comp = lustre.component(init:, update:, view:, options: [
    // Don't inherit parent styles
    component.adopt_styles(False),
    
    // React to attribute changes
    component.on_attribute_change("value", fn(value) {
      Ok(ParentSetValue(value))
    }),
    
    // React to property changes (for complex values)
    component.on_property_change("items", {
      decode.list(decode.string)
      |> decode.map(ParentSetItems)
    }),
    
    // React to context from ancestors
    component.on_context_change("theme", {
      use theme <- decode.field("theme", decode.string)
      decode.success(ThemeChanged(theme))
    }),
  ])
  
  lustre.register(comp, tag)
}

// Public element function
pub fn element(
  attributes: List(Attribute(msg)),
  children: List(Element(msg)),
) -> Element(msg) {
  element.element(tag, attributes, children)
}
```

## Opaque Types for Public APIs

Encapsulate internal structure:

```gleam
/// An accordion item with heading and collapsible panel.
pub opaque type Item(msg) {
  Item(
    name: String,
    attributes: List(Attribute(msg)),
    heading: Element(msg),
    panel: Panel(msg),
  )
}

/// Create an accordion item.
pub fn item(
  name name: String,
  attributes attributes: List(Attribute(msg)),
  heading heading: Element(msg),
  panel panel: Panel(msg),
) -> Item(msg) {
  Item(name:, attributes:, heading:, panel:)
}
```

## Effects

```gleam
import lustre/effect

// No effect
effect.none()

// Batch multiple effects
effect.batch([effect1, effect2, effect3])

// Custom effect
effect.from(fn(dispatch) {
  // Do something async
  dispatch(SomethingHappened(result))
})

// Emit custom event (for components)
event.emit("my-event", json.object([
  #("value", json.string(value)),
]))

// Provide context to descendants
effect.provide("context-name", json.object([
  #("theme", json.string("dark")),
]))
```

## Event Handling with Decoders

```gleam
import gleam/dynamic/decode
import lustre/event

// Simple event
pub fn on_click(msg: msg) -> Attribute(msg) {
  event.on_click(msg)
}

// Custom event with detail
pu
Files: 1
Size: 16.6 KB
Complexity: 20/100
Category: Design

Related in Design