Quickstart
off_topic adds subscriptions to Lustre — a way to stay in sync with the browser while your application is running. You can listen for keyboard events, track whether the tab is visible, react to user preference changes, and more, all without writing any FFI.
Installation
Add off_topic to your Gleam project:
gleam add lustre off_topic
off_topic targets JavaScript, so make sure your gleam.toml has target = "javascript":
name = "my_app"
version = "1.0.0"
+ target = "javascript"
You should already have this line in your gleam.toml file!
If you don’t, I recommend you to first go through the Lustre Quickstart Guide
before continuing here!
Wiring it up
The entry point is off_topic.application — a drop-in replacement for
lustre.application that adds one extra callback: subscriptions.
import lustre
import off_topic
pub fn main() {
let app = off_topic.application(init:, update:, subscriptions:, view:)
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
The subscriptions callback receives your current model and returns a
Subscription(message) — the set of browser events the runtime should currently
be listening to. Everything else — init, update, view — works exactly as it
does in a plain Lustre application.
The simplest subscriptions callback returns nothing:
fn subscriptions(_model: Model) -> off_topic.Subscription(Msg) {
off_topic.none()
}
off_topic calls subscriptions after every update. When the result changes,
it stops the subscriptions that disappeared and starts the new ones. You never
manage listener lifecycles by hand.
Your first subscription
Let’s build a counter that ticks once per second and pauses when the browser tab loses focus. That takes two subscriptions: a repeating timer and the page lifecycle state.
First, the model and messages:
import gleam/int
import gleam/time/duration
import gleam/time/timestamp.{type Timestamp}
import lustre/effect.{type Effect}
import off_topic.{type PageState, type Subscription}
type Model {
Model(count: Int, page_state: PageState)
}
type Msg {
Ticked(Timestamp)
PageStateChanged(PageState)
}
fn init(_flags) -> #(Model, Effect(Msg)) {
#(Model(count: 0, page_state: off_topic.Active), effect.none())
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
Ticked(_) -> #(Model(..model, count: model.count + 1), effect.none())
PageStateChanged(state) -> #(Model(..model, page_state: state), effect.none())
}
}
Now the subscriptions function:
fn subscriptions(model: Model) -> Subscription(Msg) {
let timer = case model.page_state {
off_topic.Active ->
off_topic.every(every: duration.seconds(1), immediate: False, on_elapsed: Ticked)
_ -> off_topic.none()
}
off_topic.batch([
off_topic.page_state(PageStateChanged),
off_topic.title("count: " <> int.to_string(model.count)),
timer,
])
}
off_topic.batch combines a list of subscriptions into one.
off_topic.page_state is an observable subscription: it dispatches the current
page state immediately when it starts, then again whenever the state changes.
off_topic.every is an event subscription: it fires only on each timer tick,
never immediately on start.
off_topic.title is a command subscription: it watches its parameters for
changes, running a side-effect whenever they do.
Because subscriptions is called after every update, the timer starts and stops
automatically as model.page_state changes: when the tab is hidden the next call
returns none() for the timer and off_topic stops it; when the tab becomes active
again, the timer starts afresh.
Components
If you’re building a Lustre custom element, replace lustre.component with
off_topic.component. The signature is identical except for the added
subscriptions callback:
import off_topic
pub fn register() {
off_topic.component(init:, update:, subscriptions:, view:, options: [])
}
Also swap your import of lustre/component for off_topic/component. It is a
drop-in replacement — every builder function (on_attribute_change, on_connect,
on_disconnect, and so on) is re-exported unchanged.
- import lustre/component
+ import off_topic/component
Subscriptions follow the client lifecycle. off_topic starts subscriptions when
the first client connects to the component and stops them when the last one
disconnects. While no clients are connected, no subscriptions are running. This
lifecycle is specific to off_topic.component — it does not apply when using
off_topic.application.
Be aware that on_disconnect — and therefore subscription cleanup — is not
guaranteed. If the browser closes the tab abruptly, the disconnect callback may
never fire.
How subscriptions are started and stopped
After every update, off_topic calls subscriptions with the new model and
compares the result to the previous one.
Subscriptions are matched by position within their batch. The first child is
compared to the first child from last time, the second to the second, and so on.
This means the shape of your batch should stay stable across updates.
When a subscription goes inactive, return none() in its slot rather than removing it. The timer in the example above already does this: it is always present in the batch,
either as an active every(…) or as none().
Once matched by position, a subscription is kept running if its dependencies haven’t changed. Built-in subscriptions derive their dependencies from their own parameters — the duration, the message constructor, and so on. Change any of those and off_topic stops the old one and starts a fresh one.
Sometimes a subscription’s behaviour depends on model state that isn’t captured in
its own parameters. watching lets you declare those extra dependencies:
off_topic.on_pointer_move(PointerMoved)
|> off_topic.watching([off_topic.dep(model.dragging)])
This restarts the subscription whenever model.dragging changes. Without
watching, the subscription’s own parameters haven’t changed, so off_topic has no
reason to restart it.
Where next
The API reference lists every function with its type signature and a short description. The guides cover the topics this one skips:
-
Subscriptions from scratch — build your own subscriptions with
from,element, andresource. -
Using off_topic with Server Components — how off_topic can be used with server-components and how it makes browser events available on the server.