Subscriptions from scratch
The convenience functions — on_key_down, window_size, every — are all
built from two primitives: from and element. This guide explains how those
primitives work and how to write your own subscriptions, including the FFI glue
that connects Gleam to browser APIs.
What a subscription is
A Subscription(message) is a description that tells the runtime “while you’re
running, watch X and dispatch Y when it changes.” It is not an active listener —
the runtime starts and stops subscriptions for you as your subscriptions
callback returns different values after each update.
This is different from an Effect(message): an effect is a one-shot operation
that fires and completes; a subscription persists until it is removed from the
subscription set.
from
from is the fundamental primitive, similar to effect.from. It takes a list
of dependencies and a setup callback. The callback receives the same dispatch
function as a Lustre Effect, but must additionally return a cleanup function.
off_topic calls setup when the subscription starts and cleanup when it
stops. This maps neatly onto the setup/teardown pattern of most browser APIs.
use dispatch <- off_topic.from(watching: [off_topic.dep("my-app/my-sub")])
let handle = set_up_something(fn(value) { dispatch(send(value)) })
fn() { tear_down_something(handle) }
Writing FFI
from subscriptions almost always need FFI to reach browser APIs. In Gleam you
declare external functions with @external and implement them in a .mjs file
next to your source.
Here is a complete document visibility subscription. First, the FFI declarations:
// src/my_app/browser.gleam
import gleam/dynamic.{type Dynamic}
@external(javascript, "./browser.mjs", "visibilityState")
pub fn visibility_state() -> String
@external(javascript, "./browser.mjs", "addVisibilityListener")
pub fn add_visibility_listener(handler: fn(Dynamic) -> Nil) -> Nil
@external(javascript, "./browser.mjs", "removeVisibilityListener")
pub fn remove_visibility_listener(handler: fn(Dynamic) -> Nil) -> Nil
The JavaScript implementation alongside it:
// src/my_app/browser.mjs
export function visibilityState() {
return document.visibilityState;
}
export function addVisibilityListener(handler) {
document.addEventListener("visibilitychange", handler);
}
export function removeVisibilityListener(handler) {
document.removeEventListener("visibilitychange", handler);
}
Then the subscription itself:
// src/my_app/subscriptions.gleam
import my_app/browser
import off_topic.{type Subscription}
pub fn visibility(on_visible handle_visible: fn(Bool) -> msg) -> Subscription(msg) {
use dispatch <- off_topic.from(watching: [])
let handler = fn(_event) {
dispatch(handle_visible(browser.visibility_state() == "visible"))
}
browser.add_visibility_listener(handler)
fn() { browser.remove_visibility_listener(handler) }
}
element
Some browser APIs need a reference to a specific DOM element —
ResizeObserver, IntersectionObserver, and MutationObserver all work this
way. This is the job of element: the setup callback receives a second
argument, the Lustre root element as a Dynamic value. The setup also runs as a
before_paint effect, so the element is guaranteed to be in the DOM.
// FFI declarations in browser.gleam
@external(javascript, "./browser.mjs", "newResizeObserver")
fn new_resize_observer(callback: fn(Int, Int) -> Nil) -> Dynamic
@external(javascript, "./browser.mjs", "observeElement")
fn observe_element(observer: Dynamic, el: Dynamic) -> Nil
@external(javascript, "./browser.mjs", "disconnectObserver")
fn disconnect_observer(observer: Dynamic) -> Nil
// browser.mjs
export function newResizeObserver(callback) {
return new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
callback(Math.round(width), Math.round(height));
});
}
export function observeElement(observer, el) {
observer.observe(el);
}
export function disconnectObserver(observer) {
observer.disconnect();
}
pub fn element_size(send: fn(Int, Int) -> msg) -> off_topic.Subscription(msg) {
use dispatch, root <- off_topic.element(watching: [])
let observer = new_resize_observer(fn(w, h) { dispatch(send(w, h)) })
observe_element(observer, root)
fn() { disconnect_observer(observer) }
}
element is JS-only — it has no server-component path. If you need a
subscription that works in both a browser SPA and a server component, use from
or element on the browser side paired with remote on the server side. See
Server components for this pattern.
Dependencies and dep
Every subscription carries a dependency list. off_topic keeps a subscription running when the dependencies haven’t changed between updates, and restarts it — cleanup, then setup — when any dependency changes.
dep(value) wraps any Gleam value as a Dependency. Values are compared
structurally: equal values are the same dependency, different values are not.
// Restart whenever model.user_id changes
use dispatch <- off_topic.from(watching: [off_topic.dep(model.user_id)])
let stream = open_sse("/users/" <> model.user_id <> "/events")
fn() { close_sse(stream) }
It’s often a good idea to always pass something - like a stable string -
as a dependency, even when the subscription doesn’t depend on any model values.
This makes sure that swapping an online subscription for a here subscription
for example properly starts and stops the respective listeners.
use dispatch <- off_topic.from(watching: [off_topic.dep("my-app/my-subscription")])
Pass an empty list to run once and never restart:
use dispatch <- off_topic.from(watching: [])
watching
watching adds extra dependencies to an already-built subscription without
changing what it listens to. It’s useful when a subscription needs to restart
based on model state that isn’t one of its own parameters:
off_topic.on_pointer_move(PointerMoved)
|> off_topic.watching([off_topic.dep(model.dragging)])
resource and watch
resource is a convenience wrapper around from for the acquire/release
pattern. setup receives dispatch and returns the resource; teardown
receives the resource and cleans it up:
off_topic.resource(
watching: [off_topic.dep(url)],
setup: fn(dispatch) { open_event_source(url, fn(data) { dispatch(send(data)) }) },
teardown: fn(es) { close_event_source(es) },
)
watch is for side effects that don’t dispatch messages — it runs its callback
once on start and again whenever dependencies change:
off_topic.watch(
watching: [off_topic.dep(model.route)],
run: fn() { log("navigated to " <> model.route) },
)
Subscription patterns
Most subscriptions you write fall into one of three shapes.
Event subscriptions
Listen for a browser event and dispatch a message each time it fires. This is what
I imagine most people think of when they hear “subscription” - the FFI
wraps a pair of addEventListener / removeEventListener calls, setup
attaches the handler, and the cleanup removes it.
Observer subscriptions
It’s often more useful though to instead immediately read some value from the DOM, sending it back to the app , before also setting up events to listen for changes to that value.
This way, we’re no longer just listening to a value, we’re synchronising some
external state with our app. This is in my opinion the most useful way to
work with subscriptions, and many of the built-in ones follow this pattern:
window_size gives you the current window size immediately and also subscribes
to the resize event etc.
Command subscriptions
React to model state with a side effect without dispatching any messages —
updating the document title, toggling a CSS class, managing focus. Setup applies
the effect, and cleanup reverses it. Use from and ignore the dispatch
argument. off_topic.watch is a shorthand for the case where there’s nothing to
reverse on cleanup.