···33[](https://hex.pm/packages/off_topic)
44[](https://hexdocs.pm/off_topic/)
5566-Declarative subscriptions (like Vues `watch` or Reacts `useEffect`) for [Lustre](https://hexdocs.pm/lustre/). Subscribe to browser events — keyboard, pointer, window size, page visibility, WebSockets, and more — without writing any FFI.
66+This is an experimtal (in design not implementation) [Lustre](https://lustre.build) runtime extension,
77+adding so-called subscriptions. Subscriptions are at their core effects with a
88+cleanup function. If you know React, Vue, or Svelte, you can think of them as
99+`useEffect` or `watch` calls. `off_topic` lets you define your active subscriptions
1010+in a declarative way, using a familiar diffing mechanism to figure out which effects
1111+to start, and which cleanup functions to run.
1212+1313+It also comes with a comprehensive library of built-in effects and subscriptions and
1414+a vast collection of little demo apps showing how to work with them. Built-in
1515+subscriptions range from simple timers to browser events to SSE and WebSockets.
1616+1717+Almost all subscriptions and effects in `off_topic` work both with client _and_ server-
1818+components using a small (~3kb min+gzip), extensible runtime custom-element
1919+in the browser.
720821```sh
922gleam add off_topic
···17301831```gleam
1932import lustre
2020-import off_topic
3333+import off_topic.{type Subscription}
21342235pub fn main() {
2336 let app = off_topic.application(init:, update:, subscriptions:, view:)
···2538 Nil
2639}
27402828-fn subscriptions(model: Model) -> off_topic.Subscription(Msg) {
4141+fn subscriptions(model: Model) -> Subscription(Msg) {
2942 off_topic.batch([
3043 // always listen for the page state / visibility
3144 off_topic.page_state(PageStateChanged),
3245 case model.page_state {
3346 // while the page is focused, run a 1 second interval
3447 off_topic.Active ->
3535- off_topic.every(every: duration.seconds(1), immediate: False, on_elapsed: Ticked)
4848+ off_topic.every(every: duration.seconds(1), immediate: False, on_elapsed: Tick)
3649 // when the page is no longer active, returning off_topic.none()
3750 // causes the runtime to clean up the interval automatically.
3851 _ -> off_topic.none()
···4154}
4255```
43564444-A subscription is an effect with cleanup — a setup function that returns a
4545-teardown function. off_topic diffs the subscription tree returned by each
4646-`update` call the same way Lustre diffs elements: subscriptions that
4747-disappeared are torn down, new ones are started, and unchanged ones are left
4848-alone.
5757+The module documentation contains many more examples and their source code for you
5858+to play with!
49595050-For Lustre server components, off_topic provides a small client-side bridge (3kb min+gzip)
5151-that runs subscriptions in the browser and forwards events to the server.
5252-Almost every built-in subscription works on both sides
5353-without any changes to your code.
6060+There's also some guides on the left that go into more detail.
54615562## Documentation
5663
+2-6
gleam.toml
···1616[documentation]
1717pages = [
1818 { title = "Quickstart", path = "guide/quickstart.html", source = "./guides/quickstart.md" },
1919- # { title = "Subscriptions from scratch", path = "guide/01-subscriptions-from-scratch.html", source = "./guides/01-subscriptions-from-scratch.md" },
2020- # { title = "Listening for events", path = "guide/02-listening-for-events.html", source = "./guides/02-listening-for-events.md" },
2121- # { title = "Commands", path = "guide/03-commands.html", source = "./guides/03-commands.md" },
2222- # { title = "Extending the ot-client runtime", path = "guide/04-extending-the-runtime.html", source = "./guides/04-extending-the-runtime.md" },
2323- # { title = "", path = "js/demo.js", source = "demos/dist/component.js" },
2424- # { title = "", path = "css/demo.css", source = "demos/dist/component.css" }
1919+ { title = "Subscriptions from scratch", path = "guide/subscriptions-from-scratch.html", source = "./guides/subscriptions-from-scratch.md" },
2020+ { title = "Server components", path = "guide/server-components.html", source = "./guides/server-components.md" }
2521]
26222723[dependencies]
+62-46
guides/quickstart.md
···1010Add off_topic to your Gleam project:
11111212```sh
1313-gleam add off_topic
1313+gleam add lustre off_topic
1414```
15151616off_topic targets JavaScript, so make sure your `gleam.toml` has `target =
···2121 version = "1.0.0"
2222+ target = "javascript"
2323```
2424+2525+You should already have this line in your `gleam.toml` file!
2626+If you don't, I recommend you to first go through the [Lustre Quickstart Guide](https://hexdocs.pm/lustre/guide/01-quickstart.html)
2727+before continuing here!
24282529## Wiring it up
2630···6468First, the model and messages:
65696670```gleam
7171+import gleam/int
6772import gleam/time/duration
6873import gleam/time/timestamp.{type Timestamp}
6974import lustre/effect.{type Effect}
···96101fn subscriptions(model: Model) -> Subscription(Msg) {
97102 let timer = case model.page_state {
98103 off_topic.Active ->
9999- off_topic.every(every: duration.seconds(1), on_elapsed: Ticked)
104104+ off_topic.every(every: duration.seconds(1), immediate: False, on_elapsed: Ticked)
100105 _ -> off_topic.none()
101106 }
102107103108 off_topic.batch([
104109 off_topic.page_state(PageStateChanged),
110110+ off_topic.title("count: " <> int.to_string(model.count)),
105111 timer,
106112 ])
107113}
···115121`off_topic.every` is an *event* subscription: it fires only on each timer tick,
116122never immediately on start.
117123124124+`off_topic.title` is a *command* subscription: it watches its parameters for
125125+changes, running a side-effect whenever they do.
126126+118127Because `subscriptions` is called after every `update`, the timer starts and stops
119128automatically as `model.page_state` changes: when the tab is hidden the next call
120129returns `none()` for the timer and off_topic stops it; when the tab becomes active
121130again, the timer starts afresh.
122131123123-## Observable values and transient events
132132+## Components
124133125125-off_topic subscriptions come in two flavours, and their names reflect the
126126-difference.
127127-128128-**Observable values** have a "current state" the browser holds persistently — the
129129-window size, whether the device prefers dark mode, whether the page is visible.
130130-Their subscriptions dispatch the current value immediately on start, then again on
131131-each change. Their names are the value itself:
134134+If you're building a Lustre custom element, replace `lustre.component` with
135135+`off_topic.component`. The signature is identical except for the added
136136+`subscriptions` callback:
132137133138```gleam
134134-off_topic.window_size(GotWindowSize) // dispatches immediately + on resize
135135-off_topic.color_scheme(GotColorScheme) // dispatches immediately + on change
136136-off_topic.online(GotOnlineStatus) // dispatches immediately + on change
139139+import off_topic
140140+141141+pub fn register() {
142142+ off_topic.component(init:, update:, subscriptions:, view:, options: [])
143143+}
137144```
138145139139-**Transient events** happen at a point in time. Their subscriptions only fire when
140140-the event occurs. Their names start with `on_`:
146146+Also swap your import of `lustre/component` for `off_topic/component`. It is a
147147+drop-in replacement — every builder function (`on_attribute_change`, `on_connect`,
148148+`on_disconnect`, and so on) is re-exported unchanged.
141149142142-```gleam
143143-off_topic.on_click(UserClicked)
144144-off_topic.on_key_down(UserPressedKey)
145145-off_topic.on_pointer_move(MouseMoved)
150150+```diff
151151+- import lustre/component
152152++ import off_topic/component
146153```
147154148148-## Slowing things down
155155+**Subscriptions follow the client lifecycle.** off_topic starts subscriptions when
156156+the first client connects to the component and stops them when the last one
157157+disconnects. While no clients are connected, no subscriptions are running. This
158158+lifecycle is specific to `off_topic.component` — it does not apply when using
159159+`off_topic.application`.
149160150150-Some events fire very rapidly. Pointer-move events, for example, can arrive
151151-hundreds of times per second. off_topic lets you rate-limit any subscription with
152152-`throttle` or `delay`:
153161154154-```gleam
155155-off_topic.on_pointer_move(MouseMoved)
156156-|> off_topic.throttle(wait: duration.milliseconds(50))
157157-```
162162+Be aware that `on_disconnect` — and therefore subscription cleanup — is not
163163+guaranteed. If the browser closes the tab abruptly, the disconnect callback may
164164+never fire.
165165+166166+## How subscriptions are started and stopped
167167+168168+After every `update`, off_topic calls `subscriptions` with the new model and
169169+compares the result to the previous one.
170170+171171+Subscriptions are matched **by position** within their batch. The first child is
172172+compared to the first child from last time, the second to the second, and so on.
173173+This means the shape of your batch should stay stable across updates.
174174+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,
175175+either as an active `every(…)` or as `none()`.
158176159159-`throttle` passes the first event through immediately, then drops the rest until
160160-`wait` has elapsed. `delay` holds the last event in a burst and dispatches it only
161161-once the subscription has been quiet for `wait` — useful for search-as-you-type:
177177+Once matched by position, a subscription is kept running if its **dependencies**
178178+haven't changed. Built-in subscriptions derive their dependencies from their own
179179+parameters — the duration, the message constructor, and so on. Change any of those
180180+and off_topic stops the old one and starts a fresh one.
181181+182182+Sometimes a subscription's behaviour depends on model state that isn't captured in
183183+its own parameters. `watching` lets you declare those extra dependencies:
162184163185```gleam
164164-off_topic.on_key_up(SearchQueryChanged)
165165-|> off_topic.delay(wait: duration.milliseconds(300))
186186+off_topic.on_pointer_move(PointerMoved)
187187+|> off_topic.watching([off_topic.dep(model.dragging)])
166188```
167189168168-You can combine both: `throttle` handles the running stream while `delay` catches
169169-the final value after the burst ends.
190190+This restarts the subscription whenever `model.dragging` changes. Without
191191+`watching`, the subscription's own parameters haven't changed, so off_topic has no
192192+reason to restart it.
170193171194## Where next
172195···174197function with its type signature and a short description. The guides cover the
175198topics this one skips:
176199177177-- [Subscriptions from scratch](./01-subscriptions-from-scratch.html) — build
178178- your own subscriptions with `from`, `element`, and `resource`. Understand how
179179- dependencies control restart behaviour and how the server-component path works.
200200+- [Subscriptions from scratch](./subscriptions-from-scratch.html) — build
201201+ your own subscriptions with `from`, `element`, and `resource`.
180202181181-- [Listening for events](./02-listening-for-events.html) — the full set of built-in
182182- `on_*` event subscriptions and when to reach for `on(target, event, decoder)`
183183- directly to handle events off_topic doesn't cover out of the box.
184184-185185-- [Commands](./03-commands.html) — the built-in commands for storage, scroll,
186186- and focus, how they work in server-component apps, and how to write your own.
187187-188188-- [Extending the ot-client runtime](./04-extending-the-runtime.html) — how to
189189- register custom subscription and command handlers in `window.OffTopic`.
203203+- [Using off_topic with Server Components](./server-components.html) — how
204204+ off_topic can be used with server-components and how it makes
205205+ browser events available on the server.
+162
guides/server-components.md
···11+# Server components
22+33+off_topic works with Lustre server components — apps whose Gleam logic runs on
44+the BEAM but whose UI lives in the browser.
55+66+## How it works
77+88+When your app runs as a server component, Gleam runs on the server and the
99+browser renders a `<lustre-server-component>` element. off_topic adds a second
1010+custom element — `ot-client-runtime` — alongside it. This element is the bridge:
1111+it receives subscription start and stop instructions from the server, runs the
1212+corresponding JavaScript handlers in the browser, and sends dispatched values
1313+back over the wire.
1414+1515+Almost all built-in subscriptions work in server components. Each one has two
1616+paths internally: a direct browser path for SPAs, and a delegated path that runs
1717+through the client runtime when the app is on the BEAM. The only exceptions are
1818+the ones marked `JS` in the API reference (e.g. Websockets and Server-sent evnets)
1919+which only have a browser path.
2020+2121+## Setting up
2222+2323+Use `off_topic.application` or `off_topic.component` as you normally would.
2424+On the server target, the `ot-client-runtime` element is injected into your
2525+`view` function automatically. You still need to load the client runtime script
2626+in the host page. Serve `priv/static/off-topic.mjs` (or the minified variant)
2727+from your web server and include it as a module script:
2828+2929+```html
3030+<script type="module" src="/off-topic.mjs"></script>
3131+```
3232+3333+Alternatively, `off_topic.script()` returns the runtime as an inline `<script>`
3434+element — useful when you don't control the host page directly.
3535+3636+The `subscriptions` callback works identically to a browser app. Subscriptions
3737+switch to their server path on their own:
3838+3939+```gleam
4040+fn subscriptions(model: Model) -> off_topic.Subscription(Msg) {
4141+ off_topic.batch([
4242+ off_topic.page_state(PageStateChanged),
4343+ off_topic.every(duration.seconds(1), False, Ticked),
4444+ ])
4545+}
4646+```
4747+4848+4949+## Slowing things down
5050+5151+Some events fire very rapidly. Pointer-move events, for example, can arrive
5252+hundreds of times per second. off_topic lets you rate-limit any subscription with
5353+`throttle` or `delay`:
5454+5555+```gleam
5656+off_topic.on_pointer_move(MouseMoved)
5757+|> off_topic.throttle(wait: duration.milliseconds(50))
5858+```
5959+6060+`throttle` passes the first event through immediately, then drops the rest until
6161+`wait` has elapsed. `delay` holds the last event in a burst and dispatches it only
6262+once the subscription has been quiet for `wait` — useful for search-as-you-type:
6363+6464+```gleam
6565+off_topic.on_key_up(SearchQueryChanged)
6666+|> off_topic.delay(wait: duration.milliseconds(300))
6767+```
6868+6969+You can combine both: `throttle` handles the running stream while `delay` catches
7070+the final value after the burst ends.
7171+7272+7373+## Extending with custom subscriptions and commands
7474+7575+The client runtime's job is to run JavaScript on behalf of the server. It does
7676+this through two registries on `window.OffTopic`: one for subscriptions (start
7777+a listener, return a cleanup) and one for commands (fire and forget). The server
7878+refers to handlers by name — `off_topic.remote` starts a named subscription,
7979+`off_topic.command` fires a named command.
8080+8181+This is also how the built-in subscriptions work under the hood. Each one
8282+uses `lustre.is_browser()` to decide which path to take: in the browser it sets
8383+up the listener directly; on the server it calls `remote` with the name of the
8484+corresponding built-in JS handler. You can follow the same pattern to write
8585+subscriptions that work in both contexts — browser SPAs and server components —
8686+from a single Gleam function. When running in the browser, set up the listener
8787+yourself with `from`; when running on the server, delegate to a named handler
8888+you register in `window.OffTopic`.
8989+9090+```gleam
9191+pub fn geolocation(send: fn(Float, Float) -> msg) -> off_topic.Subscription(msg) {
9292+ let decoder = {
9393+ use lat <- decode.field("lat", decode.float)
9494+ use lng <- decode.field("lng", decode.float)
9595+ decode.success(send(lat, lng))
9696+ }
9797+ off_topic.remote(name: "my-app/geolocation", with: [], run: decoder)
9898+}
9999+```
100100+101101+The corresponding JavaScript handler starts the watch and returns the cleanup:
102102+103103+```javascript
104104+window.OffTopic.subscriptions["my-app/geolocation"] = (dispatch) => {
105105+ const id = navigator.geolocation.watchPosition((pos) => {
106106+ dispatch({ lat: pos.coords.latitude, lng: pos.coords.longitude });
107107+ });
108108+ return () => navigator.geolocation.clearWatch(id);
109109+};
110110+```
111111+112112+If you want the same subscription to also work in a browser SPA — without going
113113+through the bridge — use `lustre.is_browser()` to branch: return `remote` on the
114114+server and a direct `from` subscription in the browser.
115115+116116+`off_topic.command` fires a one-shot JavaScript handler from an effect:
117117+118118+```gleam
119119+pub fn scroll_to(selector: String) -> Effect(msg) {
120120+ off_topic.command("my-app/scroll-to", [json.string(selector)])
121121+}
122122+```
123123+124124+Register both in `window.OffTopic` in the host page:
125125+126126+```javascript
127127+window.OffTopic ??= {};
128128+window.OffTopic.subscriptions ??= {};
129129+window.OffTopic.commands ??= {};
130130+131131+window.OffTopic.subscriptions["my-app/media-query"] = (query, dispatch) => {
132132+ const mq = window.matchMedia(query);
133133+ const handler = () => dispatch(mq.matches);
134134+ handler();
135135+ mq.addEventListener("change", handler);
136136+ return () => mq.removeEventListener("change", handler);
137137+};
138138+139139+window.OffTopic.commands["my-app/scroll-to"] = (selector) => {
140140+ document.querySelector(selector)?.scrollIntoView({ behavior: "smooth" });
141141+};
142142+```
143143+144144+The `??=` guards let your setup code and the runtime initialise in any order.
145145+See [Extending the ot-client runtime](./04-extending-the-runtime.html) for the
146146+full handler API, including how to dispatch DOM events and use the host element.
147147+148148+149149+## `including`
150150+151151+When a subscription runs through the server-component bridge, the value passed
152152+to `dispatch` is serialised and sent over the wire. If you dispatch a raw
153153+browser event, `including` restricts which fields are forwarded:
154154+155155+```gleam
156156+off_topic.on(on: off_topic.Document, event: "keydown", run: my_decoder)
157157+|> off_topic.including(["key", "code", "ctrlKey"])
158158+```
159159+160160+`including` has no effect on client-side `from` or `element` subscriptions.
161161+The built-in `on_key_*` functions already call `including` with the correct
162162+fields internally.
+237
guides/subscriptions-from-scratch.md
···11+# Subscriptions from scratch
22+33+The convenience functions — `on_key_down`, `window_size`, `every` — are all
44+built from two primitives: `from` and `element`. This guide explains how those
55+primitives work and how to write your own subscriptions, including the FFI glue
66+that connects Gleam to browser APIs.
77+88+## What a subscription is
99+1010+A `Subscription(message)` is a description that tells the runtime "while you're
1111+running, watch X and dispatch Y when it changes." It is not an active listener —
1212+the runtime starts and stops subscriptions for you as your `subscriptions`
1313+callback returns different values after each update.
1414+1515+This is different from an `Effect(message)`: an effect is a one-shot operation
1616+that fires and completes; a subscription persists until it is removed from the
1717+subscription set.
1818+1919+## `from`
2020+2121+`from` is the fundamental primitive, similar to `effect.from`. It takes a list
2222+of dependencies and a setup callback. The callback receives the same `dispatch`
2323+function as a Lustre `Effect`, but must additionally return a cleanup function.
2424+off_topic calls setup when the subscription starts and cleanup when it
2525+stops. This maps neatly onto the setup/teardown pattern of most browser APIs.
2626+2727+```gleam
2828+use dispatch <- off_topic.from(watching: [off_topic.dep("my-app/my-sub")])
2929+let handle = set_up_something(fn(value) { dispatch(send(value)) })
3030+fn() { tear_down_something(handle) }
3131+```
3232+3333+3434+## Writing FFI
3535+3636+`from` subscriptions almost always need FFI to reach browser APIs. In Gleam you
3737+declare external functions with `@external` and implement them in a `.mjs` file
3838+next to your source.
3939+4040+Here is a complete document visibility subscription. First, the FFI declarations:
4141+4242+```gleam
4343+// src/my_app/browser.gleam
4444+import gleam/dynamic.{type Dynamic}
4545+4646+@external(javascript, "./browser.mjs", "visibilityState")
4747+pub fn visibility_state() -> String
4848+4949+@external(javascript, "./browser.mjs", "addVisibilityListener")
5050+pub fn add_visibility_listener(handler: fn(Dynamic) -> Nil) -> Nil
5151+5252+@external(javascript, "./browser.mjs", "removeVisibilityListener")
5353+pub fn remove_visibility_listener(handler: fn(Dynamic) -> Nil) -> Nil
5454+```
5555+5656+The JavaScript implementation alongside it:
5757+5858+```javascript
5959+// src/my_app/browser.mjs
6060+export function visibilityState() {
6161+ return document.visibilityState;
6262+}
6363+6464+export function addVisibilityListener(handler) {
6565+ document.addEventListener("visibilitychange", handler);
6666+}
6767+6868+export function removeVisibilityListener(handler) {
6969+ document.removeEventListener("visibilitychange", handler);
7070+}
7171+```
7272+7373+Then the subscription itself:
7474+7575+```gleam
7676+// src/my_app/subscriptions.gleam
7777+import my_app/browser
7878+import off_topic.{type Subscription}
7979+8080+pub fn visibility(on_visible handle_visible: fn(Bool) -> msg) -> Subscription(msg) {
8181+ use dispatch <- off_topic.from(watching: [])
8282+ let handler = fn(_event) {
8383+ dispatch(handle_visible(browser.visibility_state() == "visible"))
8484+ }
8585+ browser.add_visibility_listener(handler)
8686+ fn() { browser.remove_visibility_listener(handler) }
8787+}
8888+```
8989+9090+## `element`
9191+9292+Some browser APIs need a reference to a specific DOM element —
9393+`ResizeObserver`, `IntersectionObserver`, and `MutationObserver` all work this
9494+way. This is the job of `element`: the setup callback receives a second
9595+argument, the Lustre root element as a `Dynamic` value. The setup also runs as a
9696+`before_paint` effect, so the element is guaranteed to be in the DOM.
9797+9898+```gleam
9999+// FFI declarations in browser.gleam
100100+@external(javascript, "./browser.mjs", "newResizeObserver")
101101+fn new_resize_observer(callback: fn(Int, Int) -> Nil) -> Dynamic
102102+103103+@external(javascript, "./browser.mjs", "observeElement")
104104+fn observe_element(observer: Dynamic, el: Dynamic) -> Nil
105105+106106+@external(javascript, "./browser.mjs", "disconnectObserver")
107107+fn disconnect_observer(observer: Dynamic) -> Nil
108108+```
109109+110110+```javascript
111111+// browser.mjs
112112+export function newResizeObserver(callback) {
113113+ return new ResizeObserver((entries) => {
114114+ const { width, height } = entries[0].contentRect;
115115+ callback(Math.round(width), Math.round(height));
116116+ });
117117+}
118118+119119+export function observeElement(observer, el) {
120120+ observer.observe(el);
121121+}
122122+123123+export function disconnectObserver(observer) {
124124+ observer.disconnect();
125125+}
126126+```
127127+128128+```gleam
129129+pub fn element_size(send: fn(Int, Int) -> msg) -> off_topic.Subscription(msg) {
130130+ use dispatch, root <- off_topic.element(watching: [])
131131+ let observer = new_resize_observer(fn(w, h) { dispatch(send(w, h)) })
132132+ observe_element(observer, root)
133133+ fn() { disconnect_observer(observer) }
134134+}
135135+```
136136+137137+`element` is JS-only — it has no server-component path. If you need a
138138+subscription that works in both a browser SPA and a server component, use `from`
139139+or `element` on the browser side paired with `remote` on the server side. See
140140+[Server components](./server-components.html) for this pattern.
141141+142142+## Dependencies and `dep`
143143+144144+Every subscription carries a dependency list. off_topic keeps a subscription
145145+running when the dependencies haven't changed between updates, and restarts it
146146+— cleanup, then setup — when any dependency changes.
147147+148148+`dep(value)` wraps any Gleam value as a `Dependency`. Values are compared
149149+structurally: equal values are the same dependency, different values are not.
150150+151151+```gleam
152152+// Restart whenever model.user_id changes
153153+use dispatch <- off_topic.from(watching: [off_topic.dep(model.user_id)])
154154+let stream = open_sse("/users/" <> model.user_id <> "/events")
155155+fn() { close_sse(stream) }
156156+```
157157+158158+It's often a good idea to always pass _something_ - like a stable string -
159159+as a dependency, even when the subscription doesn't depend on any model values.
160160+This makes sure that swapping an `online` subscription for a `here` subscription
161161+for example properly starts and stops the respective listeners.
162162+163163+```gleam
164164+use dispatch <- off_topic.from(watching: [off_topic.dep("my-app/my-subscription")])
165165+```
166166+167167+Pass an empty list to run once and never restart:
168168+169169+```gleam
170170+use dispatch <- off_topic.from(watching: [])
171171+```
172172+173173+## `watching`
174174+175175+`watching` adds extra dependencies to an already-built subscription without
176176+changing what it listens to. It's useful when a subscription needs to restart
177177+based on model state that isn't one of its own parameters:
178178+179179+```gleam
180180+off_topic.on_pointer_move(PointerMoved)
181181+|> off_topic.watching([off_topic.dep(model.dragging)])
182182+```
183183+184184+## `resource` and `watch`
185185+186186+`resource` is a convenience wrapper around `from` for the acquire/release
187187+pattern. `setup` receives `dispatch` and returns the resource; `teardown`
188188+receives the resource and cleans it up:
189189+190190+```gleam
191191+off_topic.resource(
192192+ watching: [off_topic.dep(url)],
193193+ setup: fn(dispatch) { open_event_source(url, fn(data) { dispatch(send(data)) }) },
194194+ teardown: fn(es) { close_event_source(es) },
195195+)
196196+```
197197+198198+`watch` is for side effects that don't dispatch messages — it runs its callback
199199+once on start and again whenever dependencies change:
200200+201201+```gleam
202202+off_topic.watch(
203203+ watching: [off_topic.dep(model.route)],
204204+ run: fn() { log("navigated to " <> model.route) },
205205+)
206206+```
207207+208208+## Subscription patterns
209209+210210+Most subscriptions you write fall into one of three shapes.
211211+212212+### Event subscriptions
213213+214214+Listen for a browser event and dispatch a message each time it fires. This is what
215215+I imagine most people think of when they hear "subscription" - the FFI
216216+wraps a pair of `addEventListener` / `removeEventListener` calls, setup
217217+attaches the handler, and the cleanup removes it.
218218+219219+### Observer subscriptions
220220+221221+It's often more useful though to instead immediately read some value from the DOM,
222222+sending it back to the app , before _also_ setting up events to listen for changes
223223+to that value.
224224+225225+This way, we're no longer just listening to a value, we're synchronising some
226226+external state with our app. This is in my opinion the most useful way to
227227+work with subscriptions, and many of the built-in ones follow this pattern:
228228+`window_size` gives you the current window size immediately _and also_ subscribes
229229+to the `resize` event etc.
230230+231231+### Command subscriptions
232232+233233+React to model state with a side effect without dispatching any messages —
234234+updating the document title, toggling a CSS class, managing focus. Setup applies
235235+the effect, and cleanup reverses it. Use `from` and ignore the `dispatch`
236236+argument. `off_topic.watch` is a shorthand for the case where there's nothing to
237237+reverse on cleanup.
+2-7
src/off_topic.gleam
···16631663///
16641664/// Dispatches `True` when online and `False` when offline.
16651665pub fn online(send message: fn(Bool) -> msg) -> Subscription(msg) {
16661666- case lustre.is_browser() {
16671667- True -> {
16681668- use dispatch <- from(watching: [dep("off-topic/online")])
16691669- subscriptions.online(fn(b) { dispatch(message(b)) })
16701670- }
16711671- False -> remote("off-topic/online", [], decode.map(decode.bool, message))
16721672- }
16661666+ use dispatch <- from(watching: [dep("off-topic/online")])
16671667+ subscriptions.online(fn(b) { dispatch(message(b)) })
16731668}
1674166916751670/// Subscribe to a Server-Sent Events stream. <small>JS</small>