lily/component

Components subscribe to the Store and re-render when their slice of the model changes. They’re functions that return renderable content, composable like React or Lustre components. That said, it’s closer to React than Lustre, with smaller, more modular components being preferable as components themselves don’t hold states.

Lily provides five component types with different performance characteristics

  1. static renders once and never updates
  2. simple uses innerHTML re-renders when the slice changes
  3. live uses patch-based updates to prevent full re-renders
  4. each handles keyed lists with innerHTML rendering
  5. each_live handles keyed lists with patch-based rendering
  6. fragment is essentially a collection of other components

simple replaces the component’s entire DOM on every slice change, which destroys focus, selection, and any in-progress user input. live applies targeted patches instead, leaving existing nodes untouched, so focus and typed text are preserved across model updates. Whenever there are elements like <input> and <textarea>, live is probably better.

The same rule applies to list components, use each_live instead of each when list items contain inputs or must not lose focus on update.

Nesting components

static, simple, and live accept a slot function as the first parameter of their content function. Call slot(child_component) wherever you want a child component to appear in the parent template. The call returns a placeholder value of your html type that is substituted with the rendered child after the parent template is serialised.

component.live(
  slice: fn(m) { m.is_active },
  initial: fn(slot) {
    html.section([attribute.class("column")], [
      html.h2([], [html.text("Title")]),
      slot(component.each_live(
        slice: fn(m) { cards_for(m) },
        key: fn(c) { c.id },
        initial: render_card,
        patch: card_patches,
      )),
    ])
  },
  patch: column_patches,
)

If no children are needed, ignore the parameter:

component.simple(
  slice: fn(m) { m.count },
  render: fn(count, _) {
    html.div([], [html.text(int.to_string(count))])
  },
)

Components work with any HTML library - Lustre or raw strings. The to_html function provided at component.mount converts your chosen library’s types to strings. We recommend Lustre elements.

To use nesting, also supply to_slot at mount — a zero-argument function that returns an html placeholder value that serialises to <lily-slot></lily-slot>. For Lustre:

component.mount(
  runtime,
  selector: "#app",
  to_html: element.to_string,
  to_slot: fn() { element.element("lily-slot", [], []) },
  view: app,
)

For raw HTML strings:

component.mount(
  runtime,
  selector: "#app",
  to_html: fn(s) { s },
  to_slot: fn() { "<lily-slot></lily-slot>" },
  view: app,
)

Each component declares a slice function that extracts relevant data from the model. The runtime caches the previous slice and skips rendering when unchanged (using reference equality by default, structural equality opt-in via component.structural).

import lily/client
import lily/component
import lily/event
import lily/store
import lustre/attribute
import lustre/element
import lustre/element/html

fn app(_model: Model) {
  component.simple(
    slice: fn(m: Model) { m.count },
    render: fn(count, _) {
      html.div([], [
        html.button([attribute.data("msg", "decrement")], [html.text("-")]),
        html.p([], [html.text(int.to_string(count))]),
        html.button([attribute.data("msg", "increment")], [html.text("+")]),
      ])
    },
  )
}

pub fn main() {
  let runtime =
    store.new(Model(count: 0), with: update)
    |> client.start

  runtime
  |> component.mount(
    selector: "#app",
    to_html: element.to_string,
    to_slot: fn() { element.element("lily-slot", [], []) },
    view: app,
  )
  |> event.on_click(selector: "#app", decoder: parse_click)
}

All components are JavaScript-only (@target(javascript)).

Types

Comparison strategy for detecting slice changes. By default, the comparison strategy uses reference equality which is more efficient. However, reference equality can cause unnecessary re-renders for some data types if the value remains the same but the reference changes, which means that structural equality may be preferred. For a rule of thumb, use the default behaviour unless the slice listened to is a List, Tuple, or a record. See component.structural for specifying structural reference.

pub type CompareStrategy {
  ReferenceEqual
  StructuralEqual
}

Constructors

  • ReferenceEqual

    Reference equality (JavaScript ===, O(1)), default

  • StructuralEqual

    Structural equality (Gleam ==, O(n)), use for tuples/lists

Component is the core type representing renderable content in Lily. The constructors for Component is kept opaque – use the associated functions to create components instead. The html type parameter is user-provided and can be any type that represents HTML markup.

pub opaque type Component(model, message, html)

Patches are DOM updates to apply to a component, avoiding a full re-render used for component.live and component.each_live. The target field is a CSS selector relative to the component’s root element, with an empty string provided if the component’s root element is itself. Patches are scoped to their component, preventing cross-component interference.

pub type Patch {
  RemoveAttribute(target: String, name: String)
  SetAttribute(target: String, name: String, value: String)
  SetStyle(target: String, property: String, value: String)
  SetText(target: String, value: String)
}

Constructors

  • RemoveAttribute(target: String, name: String)

    Remove an HTML attribute

  • SetAttribute(target: String, name: String, value: String)

    Set an HTML attribute

  • SetStyle(target: String, property: String, value: String)

    Set a CSS style property

  • SetText(target: String, value: String)

    Set the textContent of an element (wipes children)

A function that accepts a child Component and returns a placeholder value of your html type marking where that child will be rendered. Passed as the first parameter of every static, simple, and live content function. Call it inline wherever you want the child to appear; call order determines DOM position.

pub type Slotter(model, message, html) =
  fn(Component(model, message, html)) -> html

Values

pub fn each(
  slice slice: fn(model) -> List(item),
  key key: fn(item) -> key,
  render render: fn(item) -> Component(model, message, html),
) -> Component(model, message, html)

Manages a dynamic list of items with add/remove/reorder reconciliation. Each item is identified by a unique key. When the list changes, only the changed items are updated. component.each differs from component.each_live in that it does a full re-render of the HTML element instead of patches.

Avoid using each for list items that contain <input>, <textarea>, or <select> elements — each changed item replaces its DOM via innerHTML, destroying focus and in-progress user input. Use each_live with targeted patches instead.

slice must return a List rather than a single element, unlike component.simple.

While the type for key can be defined by the user, internally, these are converted to String.

The render function is called for each item and returns a Component. For plain HTML items, wrap with component.static.

component.each(
  slice: fn(model) { model.counters },
  key: fn(counter) { counter.id },
  render: fn(counter) {
    component.static(fn(_) {
      html.div([class("counter")], [
        html.text(int.to_string(counter.value))
      ])
    })
  }
)
pub fn each_live(
  slice slice: fn(model) -> List(item),
  key key: fn(item) -> key,
  initial initial: fn(item) -> Component(model, message, html),
  patch patch: fn(item) -> List(Patch),
) -> Component(model, message, html)

Manages a dynamic list of items with add/remove/reorder reconciliation. Each item is identified by a unique key. When the list changes, only the changed items are updated. component.each_live differs from component.each in that patches to the DOM element are applied instead of a full re-render. This is useful when list items are updated frequently.

slice must return a List rather than a single element, unlike component.live.

While the type for key can be defined by the user, internally, these are converted to String.

The initial function returns a Component for each item’s first render. Wrap plain HTML with component.static. The patch function returns patches applied on updates (the item’s root must remain).

component.each_live(
  slice: fn(model) { model.series },
  key: fn(series) { series.id },
  initial: fn(series) {
    component.static(fn(_) {
      html.div([class("display-data")], [
        html.span([class("value")], [html.text("0")])
      ])
    })
  },
  patch: fn(series) {
    [SetText(".value", int.to_string(series.value))]
  },
)
pub fn fragment(
  children: List(Component(model, message, html)),
) -> Component(model, message, html)

Fragments allow you to return multiple components from a single function. The children are rendered in order and concatenated into the parent’s HTML. This is similar to Lustre’s [element.fragment][https://hexdocs.pm/lustre/lustre/element.html#fragment].

fn app(_model: Model) -> Component(Model, Message, Element(Message)) {
  component.fragment([
    component.static(fn(_) { html.h1([], [html.text("My App")]) }),
    component.simple(...),
    component.each(...),
  ])
}
pub fn live(
  slice slice: fn(model) -> a,
  initial initial: fn(fn(Component(model, message, html)) -> html) -> html,
  patch patch: fn(a) -> List(Patch),
) -> Component(model, message, html)

Live components render an initial HTML structure once, then apply DOM patches on subsequent updates. This avoids the full innerHTML replacement of simple, which means existing nodes are never destroyed between updates.

Use live whenever the component contains <input>, <textarea>, or <select> elements. Because the DOM nodes are preserved, focus, cursor position, and any in-progress user input survive model updates. This also makes live the right choice for high-frequency updates such as drag-and-drop, animations, and real-time data (60fps rendering).

The patch function returns a list of Patch values. Each patch targets an element relative to the component’s root using a CSS selector.

The first parameter of initial is a Slotter — call slot(child_component) wherever you want a nested component to appear. Ignore it with _ if no children are needed.

Example

component.live(
  slice: fn(model) { model.data },
  initial: fn(_) {
    html.div([], [
      html.span([class("value")], [html.text("0")]),
      html.div([class("bar")], [])
    ])
  },
  patch: fn(data) {
    [
      SetText(".value", int.to_string(data)),
      SetStyle(".bar", "width", int.to_string(data) <> "%"),
    ]
  }
)
pub fn mount(
  runtime: client.Runtime(model, message),
  selector selector: String,
  to_html to_html: fn(html) -> String,
  to_slot to_slot: fn() -> html,
  view view: fn(model) -> Component(model, message, html),
) -> client.Runtime(model, message)

This is the entry point for rendering, mounting a component tree to a specific DOM element. It creates a subscription to the store and renders the entire component tree whenever the model changes.

  • selector: CSS selector for the mount point (e.g., "#app")
  • to_html: Function to convert html type to String (e.g., element.to_string for Lustre or fn(html) {html} for raw HTML strings)
  • to_slot: Zero-argument function returning an html placeholder value that serialises to <lily-slot></lily-slot>. Used when nesting components via Slotter. For Lustre: fn() { element.element("lily-slot", [], []) }. For raw HTML strings: fn() { "<lily-slot></lily-slot>" }.
  • view: Function that takes the model and returns the root component tree
runtime
|> component.mount(
  selector: "#app",
  to_html: element.to_string,
  to_slot: fn() { element.element("lily-slot", [], []) },
  view: app,
)
pub fn require_connection(
  component: Component(model, message, html),
  connected connected: fn(model) -> Bool,
) -> Component(model, message, html)

When you want to disable a component when the transport is disconnected, this allows you to do that. The connected function extracts the connection status from the model. When it returns False, Lily adds data-lily-disabled="true" and aria-disabled="true" attributes plus a lily-disconnected CSS class to the component’s root element, and prevents all event handlers from firing. Custom styling, such as greying the component out or changing opacity, can be achieved with simple CSS styling.

Pipe this after creating a component.

component.simple(
  slice: fn(model) { model.transfer_amount },
  render: fn(amount, _) {
    html.button([], [html.text("Transfer $" <> int.to_string(amount))])
  },
)
|> component.require_connection(fn(model) { model.connected })
pub fn simple(
  slice slice: fn(model) -> a,
  render render: fn(
    a,
    fn(Component(model, message, html)) -> html,
  ) -> html,
) -> Component(model, message, html)

This is the most common component type. It subscribes to a slice of the model and re-renders the entire component when that slice changes.

The render function receives the slice value and a Slotter. Call slot(child_component) wherever you want a nested component to appear, or ignore the slot parameter with _ if no children are needed.

Avoid using simple for components that contain <input>, <textarea>, or <select> elements — every slice change replaces the component’s entire DOM via innerHTML, which destroys focus and any in-progress user input. Use live with targeted patches instead.

component.simple(
  slice: fn(model) { model.count },
  render: fn(count, _) {
    html.div([], [html.text("Count: " <> int.to_string(count))])
  }
)
pub fn static(
  content content: fn(fn(Component(model, message, html)) -> html) -> html,
) -> Component(model, message, html)

Static components render once and never update. Useful for headers, static text, or any content that doesn’t depend on the model.

The content function receives a Slotter. Call slot(child_component) wherever you want a nested component to appear, or ignore the slot parameter with _ if no children are needed.

component.static(fn(_) { html.h1([], [html.text("My App")]) })
pub fn structural(
  component: Component(model, message, html),
) -> Component(model, message, html)

Switch a component’s comparison strategy from reference to structural equality. By default, components use reference equality (===) to detect slice changes. This works well for primitives and unchanged references.

Use structural() when your slice function returns new tuples, lists, or other constructed values on every call.

Also see component.CompareStrategy.

component.simple(
  slice: fn(model) { #(model.x, model.y) },  // Returns new tuple each time
  render: fn(pos, _) { ... }
)
|> component.structural  // Enable deep equality check
Search Document