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.
Lily provides five component types with different performance
characteristics: static renders once and never updates,
simple uses innerHTML re-renders when the slice changes
(most common), live uses patch-based updates for 60fps
performance, each handles keyed lists with innerHTML
rendering, and each_live handles keyed lists with
patch-based rendering.
Components work with any HTML library - Lustre, Nakai, or raw strings.
The to_html function provided at component.mount converts
your chosen library’s types to strings. We recommend
Lustre elements
import lily/component
import lustre/element.{type Element}
import lustre/element/html
// View function
fn counter_view(count: Int) -> Element(Message) {
html.div([], [
html.p([], [element.text("Count: " <> int.to_string(count))]),
html.button([event.on_click(Increment)], [element.text("+")]),
])
}
pub fn main() {
store.new(Model(count: 0), with: update)
|> component.mount("#app", to_html: element.to_string, view: counter_view)
|> component.simple(
selector: "#counter",
slice: fn(model) { model.count },
render: fn(count) { html.p([], [element.text(int.to_string(count))]) },
)
|> client.start
}
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).
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
-
ReferenceEqualReference equality (JavaScript
===, O(1)), default -
StructuralEqualStructural 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)
Values
pub fn each(
slice slice: fn(model) -> List(item),
key key: fn(item) -> key,
render render: fn(item) -> 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.
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 should return HTML (in
whatever type is defined on component.mount).
Example
component.each(
slice: fn(model) { model.counters },
key: fn(counter) { counter.id },
render: fn(counter) {
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) -> 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 renders the initial HTML for each item.
The patch function returns patches to apply on updates.
Example
component.each_live(
items: fn(model) { model.series },
key: fn(series) { series.data.id },
initial: fn(data) {
html.div([class("display-data")], [
html.span([class("value")], [html.text("0")])
])
},
patch: fn(series) {
[SetText(".value", int.to_string(series.data.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].
Example
fn app() -> Component(Model, Message, Element(Message)) {
component.fragment([
component.static(html.h1([], [html.text("My App")])),
component.simple(...),
component.each(...),
])
}
pub fn live(
slice slice: fn(model) -> a,
initial initial: 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 is much faster than innerHTML, replacement for frequent updates (e.g., drag-and-drop, animations, real-time data) for 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.
Example
component.live(
slice: fn(model) { model.data },
initial: 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,
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.
Parameters
store: The application storeselector: CSS selector for the mount point (e.g.,"#app")to_html: Function to converthtmltype toString(e.g.,element.to_stringfor Lustre orfn(html) {html}for raw HTML strings)view: Function that takes the model and returns the root component tree
Example
runtime
|> component.mount(selector: "#app", to_html: element.to_string, 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.
Example
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) -> 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 should return HTML (in whatever type is defined on
component.mount).
Example
component.simple(
slice: fn(model) { model.count },
render: fn(count) {
html.div([], [html.text("Count: " <> int.to_string(count))])
}
)
pub fn static(content: 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.
Example
component.static(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.
Example
component.simple(
slice: fn(model) { #(model.x, model.y) }, // Returns new tuple each time
render: fn(pos) { ... }
)
|> component.structural // Enable deep equality check