lily/component

Components subscribe to the Store and re-render when their slice of the model changes. They are stateless components and focus more on rendering, so are fairly light (lighter than Lustre or LiveView components, for example) and having a lot of components is not an anti-pattern in Lily.

Each component is a ComponentType plus zero or more Decorations (the things you pipe on, like a transition or an event listener). You never reach for the constructors directly, you call the builder function for the type you want.

There are seven types, each with its own performance profile:

  1. static renders once and never updates
  2. simple re-renders via innerHTML when the slice changes
  3. live applies targeted patches instead of full re-renders
  4. each handles keyed lists with innerHTML rendering
  5. each_live handles keyed lists with patch-based rendering
  6. fragment groups other components into one slot
  7. switch renders one of several children by a discriminator, keeping DOM identity while the discriminator is unchanged

simple swaps the component’s entire DOM on every slice change, which wipes focus, selection, and any half-typed input. live applies targeted patches instead and leaves existing nodes untouched, so focus and typed text survive the update. Anywhere there’s an <input> or <textarea>, live is almost certainly what you want, and the same rule carries to lists, prefer each_live over each when items contain inputs or must not lose focus.

On top of its type, a component carries decorations, each applied with a pipe: transition adds CSS enter/exit classes timed to a duration (with deferred DOM removal so the exit animation finishes before the element leaves), event.on and friends attach listeners, scoped fixes the subtree an event confines itself to, and require_connection gates the subtree on connection status.

static, simple, and live hand their content function a slot function as its first argument. Call slot(child_component) wherever you want a child to appear in the parent template, it returns a placeholder of your html type that gets swapped for the rendered child once the parent serialises. Nest as deep as you like:

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

When a component has no children, just ignore the parameter:

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

Components work with any HTML library, Lustre or raw strings, whatever you like. The to_html function you pass at mount converts your chosen library’s types into strings; we’d recommend Lustre elements. If you use nesting, also pass to_slot, a zero-argument function returning an html placeholder 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,
)

Or with raw HTML strings:

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

Every component declares a slice that pulls just the data it needs out of the model. The runtime caches the last slice and skips rendering when it’s unchanged, using reference equality by default, or structural equality if you pipe on structural (handy when the slice builds a new tuple or record each time). Keep slices cheap and do the heavy lifting in render, which the comparison gates. Here’s the whole thing end to end:

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

fn decrement_button() {
  html.button([attribute.data("message", "decrement")], [html.text("-")])
}

fn increment_button() {
  html.button([attribute.data("message", "increment")], [html.text("+")])
}

fn app(_model: Model) {
  component.simple(
    slice: fn(model: Model) { model.count },
    render: fn(count, _) {
      html.div([], [
        decrement_button(),
        html.p([], [html.text(int.to_string(count))]),
        increment_button(),
      ])
    },
  )
  |> event.on_global_decoded(
    event: event.click,
    selector: "#app",
    decoder: parse_click,
  )
}

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

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

Event handlers pipe onto the component they belong to via event.on(), and the walk that mount does registers each binding once at startup. Events declared inside each and each_live item bodies are not collected, so put them on the each/each_live wrapper or any static ancestor instead (probably a div).

Building components and rendering them to a string with render_to_string work on both targets so that the server can render initial components.

mount and event handling are JavaScript-only, since they drive a live DOM.

Types

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.

Each dynamic ComponentType variant (all but Static and Fragment) carries a compare_structural flag. False (the default) means slice changes are detected by reference equality (===, O(1)); True means structural equality (==, O(n)) and is set by piping a component through structural. Use structural when the slice constructs new tuples, lists, or records on every call.

Components compile on both targets. The constructor functions and the pure walker render_to_string work on Erlang and JavaScript alike. mount is JavaScript-only because it mutates the live DOM.

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. The type compiles on both targets so it can appear in the Component’s patch-bearing variants on Erlang too; the patches themselves are only applied by mount, which is JavaScript-only.

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.

Event bindings declared inside render are not collected. Attach per-list events to this each component or any ancestor (selectors are global, so one handler on .card covers every card).

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).

Event bindings declared inside initial are not collected. Attach per-list events to this each_live component or any ancestor (selectors are global, so one handler covers every item).

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. Similar to Lustre’s element.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, renders the entire component tree, and walks the tree to register every event binding attached via event.on() and friends.

  • 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

mount can be called more than once on a shared runtime, with different selectors, to drive multiple DOM roots from one model. This is how overlays / portals work: mount your main view at #app and a secondary overlays view at #overlays. Both views subscribe to the same model and update on every dispatch. Calling mount twice on the same selector tears down the previous mount and replaces it.

runtime
|> component.mount(
  selector: "#app",
  to_html: element.to_string,
  to_slot: fn() { element.element("lily-slot", [], []) },
  view: app,
)
pub fn render_to_string(
  view view: fn(model) -> Component(model, message, html),
  model model: model,
  to_html to_html: fn(html) -> String,
  from_string from_string: fn(String) -> html,
) -> String

Render a view to an HTML string without touching the DOM. Walks the Component tree, calling each render / initial / content function and piping through to_html. Compiles on both targets, so it can produce the initial page markup ahead of time (at build time, or from a plain request handler) rather than on a live DOM. Pair with transport.encode_initial_snapshot to embed the matching initial state and client.hydrate so the client adopts the pre-rendered DOM instead of re-rendering it on load. This is static pre-rendering plus hydration from a fixed snapshot, not per-request server-side rendering.

Nested components placed via the Slotter callback are rendered inline: the walker renders each child to a string and uses from_string to wrap that string back as an html value the user composes into the parent. For raw-HTML libraries where html is just String, from_string is the identity. For Lustre, pass an unsafe_raw_html-style constructor that inserts the string verbatim.

Event bindings, focus management, and CSS transitions are skipped: they only make sense on a live DOM. For live and each_live, the initial baseline is rendered; patches only apply at runtime via mount.

let html = component.render_to_string(
  view: shared.view,
  model: shared.initial_model(),
  to_html: element.to_string,
  from_string: element.unsafe_raw_html(_, "div", [], _),
)
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))])
  }
)

Pipe through event.on() and friends to attach DOM event handlers to the rendered subtree, registered once at mount.

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.

Static and Fragment components don’t compare slices, so this returns them unchanged; decorations are left untouched.

component.simple(
  slice: fn(model) { #(model.x, model.y) },  // Returns new tuple each time
  render: fn(pos, _) { ... }
)
|> component.structural  // Enable deep equality check
pub fn switch(
  on slice: fn(model) -> a,
  case_of build: fn(a) -> Component(model, message, html),
) -> Component(model, message, html)

Single-slot dynamic switching with identity preservation. slice picks a discriminator from the model; build turns that discriminator into a Component. When the slice value is unchanged across renders, the wrapper and child DOM are not touched, so focus, selection, and in-progress input survive. When the slice changes, the old child’s handlers are unregistered, the new Component is built and rendered, and the wrapper’s innerHTML is replaced.

Switch compares by reference equality by default, pipe through structural when the slice constructs new values on every call (tuples, records). Pair with event.on() on the Switch itself to bind events: bindings declared inside build’s returned Component are not collected at mount and never fire. Selectors are global, so one handler on .panel-close covers every panel rendered by the switch.

Example

component.switch(
  on: fn(model: Model) { model.route },
  case_of: fn(route) {
    case route {
      Home -> home_page()
      Profile -> profile_page()
      Settings -> settings_page()
    }
  },
)
pub fn transition(
  component: Component(model, message, html),
  enter enter: String,
  exit exit: String,
  duration_milliseconds duration_milliseconds: Int,
) -> Component(model, message, html)

Decorate a component with enter and exit CSS classes timed to a duration. Pipe-friendly: the component comes first, so it chains like the other decorators (event.on*, require_connection). On mount, the wrapper carries enter for duration_milliseconds, then the class is removed. On unmount (when an enclosing each, each_live, or switch removes the wrapper), exit is applied and DOM removal is deferred by the same duration, with animationend taking precedence if the CSS fires it first.

The CSS contract is keyframes-based:

.dialog-enter { animation: dialog-enter 200ms; }
.dialog-exit  { animation: dialog-exit 200ms forwards; }
@keyframes dialog-enter { from { opacity: 0 } to { opacity: 1 } }
@keyframes dialog-exit  { from { opacity: 1 } to { opacity: 0 } }

forwards on exit keeps the final state visible while the framework holds the element in the DOM, preventing a flicker before removal.

Placement rule: transitions fire only when the framework’s removal path runs through them. That happens for each, each_live, and switch child removal. Placing a transition inside a simple’s render does not run exits on parent re-render, since simple’s innerHTML wipe is synchronous. Hoist the transition to an each_live item or a switch child if you need exits to fire.

Example

component.each_live(
  slice: fn(model) { model.toasts },
  key: fn(toast) { int.to_string(toast.id) },
  initial: fn(toast) {
    component.static(fn(_) { render_toast(toast) })
    |> component.transition(
      enter: "toast-enter",
      exit: "toast-exit",
      duration_milliseconds: 200,
    )
  },
  patch: fn(_) { [] },
)
Search Document