Live components
A live component is a piece of interactive UI whose state and logic run on the server. The browser holds a small client that sends events, such as clicks and key presses, up a WebSocket, and applies the changes to the page that come back.
You write no JavaScript and no JSON API. The component can use your database and services directly, because it is already on the server, and the visitor cannot see or tamper with its logic. The cost is a connection held open per visitor and a round trip for every interaction, so it suits dashboards, forms and admin tools better than a drawing app.
Under the hood these are Lustre server
components. howdy/ui/live connects them to Howdy's
WebSockets.
A counter
A component is a Lustre app, made of three functions:
-
initmakes the starting state, called the model. -
updatetakes the model and a message, and returns the new model. -
viewturns the model into elements.
import gleam/int
import howdy/ui
import howdy/ui/button.{Primary, Secondary}
import lustre
import lustre/element.{text}
import lustre/event
pub type Msg {
Increment
Decrement
}
pub fn app() {
lustre.simple(init, update, view)
}
fn init(start: Int) -> Int {
start
}
fn update(count: Int, msg: Msg) -> Int {
case msg {
Increment -> count + 1
Decrement -> count - 1
}
}
fn view(count: Int) {
ui.card([], [
ui.h2("Counter"),
ui.p([text(int.to_string(count))]),
ui.row([], [
ui.button(Secondary, [event.on_click(Decrement)], [text("-")]),
ui.button(Primary, [event.on_click(Increment)], [text("+")]),
]),
])
}
Events in the view, such as event.on_click(Increment), name the message
to send. When the button is clicked, the message goes to the server,
update runs, view runs with the new model, and Lustre sends the browser
only what changed.
lustre.simple is for components that just hold state. For one that also
needs to do things, such as load data or set a timer, use
lustre.application, whose init and update also return effects. The
Lustre documentation covers these.
Serving and mounting
A component needs a route for its socket:
import howdy/controller
import howdy/ui/live
controller.new("counter")
|> controller.get("/", fn(ctx) { live.serve(ctx, counter.app(), with: 0) })
The with: value is passed to init, here as the starting count. It is how
a handler hands the component what it needs, such as an id from the path
or the signed-in user.
Then give it a place in a page:
import howdy/ui/page
page.new("Counter")
|> page.live
|> page.body([ui.container([], [live.mount("/counter")])])
|> page.respond(ctx)
page.live adds the client script to the page, and is needed once however
many components the page has. live.mount takes the path of the socket
route, and marks where the component appears.
Each connection gets its own copy of the component, started when the socket
opens and shut down when it closes. Two visitors, or two tabs, have two
separate counts. A reload starts again from init, so anything that must
survive one belongs in your database, not only in the model.
One component for everyone
Sometimes every visitor should see the same thing: a dashboard, a scoreboard, a list of who is online. Start one component when your app starts, and attach every socket to it:
pub fn main() {
let assert Ok(scoreboard) = live.start(scoreboard.app(), with: Nil)
howdy.new()
|> howdy.controller(pages(scoreboard))
|> howdy.start
}
controller.get("/scoreboard", fn(ctx) { live.serve_shared(ctx, scoreboard) })
live.start returns a runtime that outlives any one connection. With
live.serve_shared, every client sees the same model, and when one visitor
clicks a button the view changes for all of them. Closing a socket detaches
that client and leaves the runtime running.
live.dispatch sends a runtime a message from anywhere in your program,
which arrives at update just as a click would:
fn record_goal(ctx: Context, scoreboard) {
live.dispatch(scoreboard, scoreboard.GoalScored("home"))
controller.status(ctx, 204)
}
That is how a background job, a webhook or an ordinary handler updates what every visitor is looking at.
Live links
Live links move between live pages without reloading the page. The page
has one outlet in place of a live.mount, and each link says which live
route to put in it:
fn layout(ctx, mount mount: String) {
page.new("Shop")
|> page.live
|> page.body([
ui.row([], [
live.link(to: "/", mount: "/live/home", children: [text("Home")]),
live.link(to: "/orders", mount: "/live/orders", children: [
text("Orders"),
]),
]),
live.outlet(mount),
])
|> page.respond(ctx)
}
pub fn pages() {
controller.new("/")
|> controller.get("/", fn(ctx) { layout(ctx, mount: "/live/home") })
|> controller.get("/orders", fn(ctx) { layout(ctx, mount: "/live/orders") })
}
/live/home and /live/orders are ordinary live routes, served with
live.serve as above.
When a visitor clicks a link, the outlet connects to the link's mount
route, and the address bar shows its to path. The old view stays on
screen until the new one arrives. Everything outside the outlet is left
alone, including other live components, which keep their connections. Back
and forward swap the outlet too.
**Every to path must serve the whole page**, with that view in the outlet,
as /orders does above. It is what a reload, a bookmark, a new tab or a
browser without JavaScript loads. Writing the page as one layout function
that takes the mount, as here, keeps them in step.
-
A click with a modifier key, a middle click, a link with a
target, and a link to another site all behave as plain links. So does every live link on a page with no outlet. - A page has at most one outlet.
- Links work inside live views as well as in the page, so a view can link to the next one.
-
live.navigate(to:, mount:)returns the attributes that make an<a>a live link, for a link you style yourself.live.linkis styled as a Howdy UI link. -
Put
live.title("Orders")anywhere in a view to change the document's title when that view mounts, so history entries are named after their page. - After a click, the page scrolls to the top and focus moves to the outlet, so keyboard and screen reader users start at the new content.
-
Each swap starts a fresh copy of the view, so its state does not carry
across: going back to a counter starts it again from
init. Views attached to a shared runtime withlive.serve_sharedshow the shared model as usual.
page.live includes the script that makes links work. If you render the
document yourself, add live.script() after server_component.script().
Who may connect
A live route is an ordinary route that ends in a WebSocket, so everything about WebSockets applies:
-
It passes through middleware and
guards. Put it on a guarded controller to
require a signed-in user, and pass the user to the component with
with:. - Browsers cannot set headers on a WebSocket handshake, so the guard must read a cookie or a query parameter.
-
Only your own site may connect by default. Other origins answer
403.
Remember that messages come from the browser. A visitor can send any
message your Msg type allows, at any time, whether or not the button for
it is on their screen. Check permissions in update, not only in view.
To change the origin rules or add callbacks of your own, live.socket and
live.socket_shared return the socket before it is upgraded, as a
howdy/websocket builder:
import howdy/websocket
live.socket(counter.app(), 0)
|> websocket.allow_origins(["https://app.example.com"])
|> websocket.upgrade(ctx)
Styling
A live component renders inside a shadow root, which keeps the page's CSS out and the component's CSS in. Howdy UI handles this for you. The component carries the CSS for exactly the classes its view uses, so components look the same inside it as outside, however the page gets its own styles.
Themes do cross into the component, because they are CSS variables, which are inherited. A live view changes colour with the rest of the page when a visitor toggles the theme.
One consequence: CSS you write by hand in the page's stylesheet does not reach inside a live component. Style its contents with components and classes instead.
Testing
A component is three plain functions, so test them directly, with no socket and no browser:
pub fn increment_adds_one_test() {
assert counter.update(1, counter.Increment) == 2
}
To test what a view shows, render it to a string:
import gleam/string
import lustre/element
pub fn view_shows_the_count_test() {
let html = counter.view(3) |> element.to_string
assert string.contains(html, ">3<")
}
Both need update and view to be public. For the route, howdy/testing
has no connection to upgrade, so a live route that gets as far as
connecting answers 426. That is enough to check the guard in front of it:
import howdy/testing
pub fn counter_needs_a_session_test() {
let res = testing.get("/counter") |> testing.send(app())
assert res.status == 401
}
