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:
staticrenders once and never updatessimplere-renders via innerHTML when the slice changesliveapplies targeted patches instead of full re-renderseachhandles keyed lists with innerHTML renderingeach_livehandles keyed lists with patch-based renderingfragmentgroups other components into one slotswitchrenders 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 yourhtmltype to aString,element.to_stringfor Lustre orfn(html) { html }for raw stringsto_slot: returns anhtmlplaceholder that serialises to<lily-slot></lily-slot>, used when nesting viaSlotterview: 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(_) { [] },
)