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

Escaping is the to_html function’s job, not Lily’s. The rendered string is written to the DOM verbatim through innerHTML. Lustre’s element.to_string escapes text and attribute values, so the Lustre path is safe. The raw-string to_html: fn(html) { html } above does not escape, so interpolating model data (which on a synced app can carry other clients’ input) straight into that string is a stored-XSS vector. When using raw strings, escape any untrusted text yourself. The from_string argument of render_to_string is the same, an unsafe_raw_html style constructor inserts its string without escaping.

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(), shared.serialiser())

  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)

A keyed dynamic list, reconciled by add/remove/reorder so only changed items update. Like each_live but each changed item is re-rendered via innerHTML rather than patched.

Avoid each for items containing <input>, <textarea>, or <select>, the innerHTML replace destroys focus and in-progress input. Use each_live there.

slice returns a List, and render returns a Component per item (wrap plain HTML with static). Keys can be any type, they are stringified internally. Event bindings inside render aren’t collected, so put per-list events on this component or any ancestor.

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)

A keyed dynamic list, reconciled by add/remove/reorder so only changed items update. Like each but items are patched instead of re-rendered, which suits frequently-updated items.

slice returns a List. initial returns the first-render Component per item (wrap plain HTML with static), and patch returns the patches for updates (the item’s root must survive). Keys can be any type, they are stringified internally. Event bindings inside initial aren’t collected, so put per-list events on this component or any ancestor.

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)

Returns several components from one function. Children render in order and concatenate into the parent’s HTML, like 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)

Renders an initial HTML structure once, then applies targeted DOM patches on update instead of the full innerHTML replace of simple, so existing nodes survive between updates.

Reach for live whenever the component holds <input>, <textarea>, or <select>, since preserving nodes keeps focus, cursor, and in-progress input intact. It also suits high-frequency updates like drag-and-drop, animation, and real-time data.

patch returns Patch values, each targeting an element under the component root by CSS selector. initial’s first parameter is a Slotter, call slot(child) where a nested component should go, or ignore it with _.

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)

The entry point for rendering. Mounts a component tree onto a DOM element, subscribes it to the store, and registers every event binding attached via event.on() and friends.

  • selector: the mount point, e.g. "#app"
  • to_html: converts your html type to a String, element.to_string for Lustre or fn(html) { html } for raw strings
  • to_slot: returns an html placeholder that serialises to <lily-slot></lily-slot>, used when nesting via Slotter
  • view: takes the model and returns the root component tree

Call mount more than once on a shared runtime, with different selectors, to drive several DOM roots from one model. This is how overlays and portals work. Mounting the same selector again replaces the previous mount.

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, walking the Component tree and piping each render through to_html. It compiles on both targets, so you can produce the initial markup ahead of time, at build time or from a plain request handler. Pair it with transport.encode_initial_snapshot and client.hydrate so the client adopts the pre-rendered DOM instead of re-rendering. This is static pre-rendering plus hydration, not per-request server-side rendering.

Nested components placed via the Slotter callback render inline, from_string wraps each child’s string back into an html value. For raw-HTML libraries it’s the identity, for Lustre pass an unsafe_raw_html-style constructor.

Event bindings, focus, and CSS transitions are skipped since they only make sense on a live DOM. For live and each_live the initial baseline renders, patches apply only 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)

Disables a component while the transport is disconnected. connected reads the connection status from the model, and when it returns False Lily adds data-lily-disabled="true", aria-disabled="true", and a lily-disconnected class to the root and stops event handlers firing. Style the disconnected state however you like with CSS. Pipe it on after building 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 scoped(
  component component: Component(model, message, html),
  selector selector: String,
) -> Component(model, message, html)

Record a component’s own CSS selector (usually #<id>) as its scope, so the scoped event.on* binders match only within its subtree. Pipe it on after giving the component an id, then pipe on event.on without a selector. Component-library builders scope their widget from the id they already render.

Example

component.simple(slice: ..., render: ...)
|> component.scoped("#search")
|> event.on(event: event.input, handler: Search)
pub fn simple(
  slice slice: fn(model) -> a,
  render render: fn(
    a,
    fn(Component(model, message, html)) -> html,
  ) -> html,
) -> Component(model, message, html)

The most common component type. It subscribes to a slice of the model and re-renders the whole component through innerHTML when that slice changes.

render receives the slice value and a Slotter, call slot(child) where a nested component should go, or ignore it with _.

Avoid simple for components holding <input>, <textarea>, or <select>, the innerHTML replace destroys focus and in-progress input. Use live there.

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)

Renders once and never updates. Good for headers, static text, or anything that doesn’t depend on the model.

content receives a Slotter, call slot(child) where a nested component should go, or ignore it with _.

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 from reference to structural equality. By default components use reference equality (===), which suits primitives and unchanged references. Reach for structural() when your slice returns new tuples, lists, or other constructed values each call.

Static and Fragment don’t compare slices, so this returns them unchanged.

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 that preserves identity. on picks a discriminator from the model and case_of turns it into a Component. While the discriminator is unchanged the wrapper and child DOM are left alone, so focus, selection, and input survive. When it changes, the old child’s handlers are unregistered and the new Component replaces the wrapper’s innerHTML.

Compares by reference by default, pipe through structural when the slice builds new values each call. Bind events with event.on() on the switch itself, bindings inside the built Component aren’t collected and never fire.

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. The component comes first so it chains like the other decorators. On mount the wrapper carries enter for duration_milliseconds, then drops it. On unmount (when an enclosing each, each_live, or switch removes it) exit is applied and DOM removal is deferred by the same duration, with animationend winning 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, which is each, each_live, and switch child removal. A transition inside a simple render won’t run exits on parent re-render, since that innerHTML wipe is synchronous. Hoist it to an each_live item or switch child if you need exits.

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