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:

Search Document