Subscriptions for Lustre!
5

Configure Feed

Select the types of activity you want to include in your feed.

guides

rebecca (May 1, 2026, 12:34 AM +0200) 24104de4 8a1f7cbd

+485 -73
+20 -13
README.md
··· 3 3 [![Package Version](https://img.shields.io/hexpm/v/off_topic)](https://hex.pm/packages/off_topic) 4 4 [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/off_topic/) 5 5 6 - 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. 6 + This is an experimtal (in design not implementation) [Lustre](https://lustre.build) runtime extension, 7 + adding so-called subscriptions. Subscriptions are at their core effects with a 8 + cleanup function. If you know React, Vue, or Svelte, you can think of them as 9 + `useEffect` or `watch` calls. `off_topic` lets you define your active subscriptions 10 + in a declarative way, using a familiar diffing mechanism to figure out which effects 11 + to start, and which cleanup functions to run. 12 + 13 + It also comes with a comprehensive library of built-in effects and subscriptions and 14 + a vast collection of little demo apps showing how to work with them. Built-in 15 + subscriptions range from simple timers to browser events to SSE and WebSockets. 16 + 17 + Almost all subscriptions and effects in `off_topic` work both with client _and_ server- 18 + components using a small (~3kb min+gzip), extensible runtime custom-element 19 + in the browser. 7 20 8 21 ```sh 9 22 gleam add off_topic ··· 17 30 18 31 ```gleam 19 32 import lustre 20 - import off_topic 33 + import off_topic.{type Subscription} 21 34 22 35 pub fn main() { 23 36 let app = off_topic.application(init:, update:, subscriptions:, view:) ··· 25 38 Nil 26 39 } 27 40 28 - fn subscriptions(model: Model) -> off_topic.Subscription(Msg) { 41 + fn subscriptions(model: Model) -> Subscription(Msg) { 29 42 off_topic.batch([ 30 43 // always listen for the page state / visibility 31 44 off_topic.page_state(PageStateChanged), 32 45 case model.page_state { 33 46 // while the page is focused, run a 1 second interval 34 47 off_topic.Active -> 35 - off_topic.every(every: duration.seconds(1), immediate: False, on_elapsed: Ticked) 48 + off_topic.every(every: duration.seconds(1), immediate: False, on_elapsed: Tick) 36 49 // when the page is no longer active, returning off_topic.none() 37 50 // causes the runtime to clean up the interval automatically. 38 51 _ -> off_topic.none() ··· 41 54 } 42 55 ``` 43 56 44 - A subscription is an effect with cleanup — a setup function that returns a 45 - teardown function. off_topic diffs the subscription tree returned by each 46 - `update` call the same way Lustre diffs elements: subscriptions that 47 - disappeared are torn down, new ones are started, and unchanged ones are left 48 - alone. 57 + The module documentation contains many more examples and their source code for you 58 + to play with! 49 59 50 - For Lustre server components, off_topic provides a small client-side bridge (3kb min+gzip) 51 - that runs subscriptions in the browser and forwards events to the server. 52 - Almost every built-in subscription works on both sides 53 - without any changes to your code. 60 + There's also some guides on the left that go into more detail. 54 61 55 62 ## Documentation 56 63
+2 -6
gleam.toml
··· 16 16 [documentation] 17 17 pages = [ 18 18 { title = "Quickstart", path = "guide/quickstart.html", source = "./guides/quickstart.md" }, 19 - # { title = "Subscriptions from scratch", path = "guide/01-subscriptions-from-scratch.html", source = "./guides/01-subscriptions-from-scratch.md" }, 20 - # { title = "Listening for events", path = "guide/02-listening-for-events.html", source = "./guides/02-listening-for-events.md" }, 21 - # { title = "Commands", path = "guide/03-commands.html", source = "./guides/03-commands.md" }, 22 - # { title = "Extending the ot-client runtime", path = "guide/04-extending-the-runtime.html", source = "./guides/04-extending-the-runtime.md" }, 23 - # { title = "", path = "js/demo.js", source = "demos/dist/component.js" }, 24 - # { title = "", path = "css/demo.css", source = "demos/dist/component.css" } 19 + { title = "Subscriptions from scratch", path = "guide/subscriptions-from-scratch.html", source = "./guides/subscriptions-from-scratch.md" }, 20 + { title = "Server components", path = "guide/server-components.html", source = "./guides/server-components.md" } 25 21 ] 26 22 27 23 [dependencies]
+62 -46
guides/quickstart.md
··· 10 10 Add off_topic to your Gleam project: 11 11 12 12 ```sh 13 - gleam add off_topic 13 + gleam add lustre off_topic 14 14 ``` 15 15 16 16 off_topic targets JavaScript, so make sure your `gleam.toml` has `target = ··· 21 21 version = "1.0.0" 22 22 + target = "javascript" 23 23 ``` 24 + 25 + You should already have this line in your `gleam.toml` file! 26 + If you don't, I recommend you to first go through the [Lustre Quickstart Guide](https://hexdocs.pm/lustre/guide/01-quickstart.html) 27 + before continuing here! 24 28 25 29 ## Wiring it up 26 30 ··· 64 68 First, the model and messages: 65 69 66 70 ```gleam 71 + import gleam/int 67 72 import gleam/time/duration 68 73 import gleam/time/timestamp.{type Timestamp} 69 74 import lustre/effect.{type Effect} ··· 96 101 fn subscriptions(model: Model) -> Subscription(Msg) { 97 102 let timer = case model.page_state { 98 103 off_topic.Active -> 99 - off_topic.every(every: duration.seconds(1), on_elapsed: Ticked) 104 + off_topic.every(every: duration.seconds(1), immediate: False, on_elapsed: Ticked) 100 105 _ -> off_topic.none() 101 106 } 102 107 103 108 off_topic.batch([ 104 109 off_topic.page_state(PageStateChanged), 110 + off_topic.title("count: " <> int.to_string(model.count)), 105 111 timer, 106 112 ]) 107 113 } ··· 115 121 `off_topic.every` is an *event* subscription: it fires only on each timer tick, 116 122 never immediately on start. 117 123 124 + `off_topic.title` is a *command* subscription: it watches its parameters for 125 + changes, running a side-effect whenever they do. 126 + 118 127 Because `subscriptions` is called after every `update`, the timer starts and stops 119 128 automatically as `model.page_state` changes: when the tab is hidden the next call 120 129 returns `none()` for the timer and off_topic stops it; when the tab becomes active 121 130 again, the timer starts afresh. 122 131 123 - ## Observable values and transient events 132 + ## Components 124 133 125 - off_topic subscriptions come in two flavours, and their names reflect the 126 - difference. 127 - 128 - **Observable values** have a "current state" the browser holds persistently — the 129 - window size, whether the device prefers dark mode, whether the page is visible. 130 - Their subscriptions dispatch the current value immediately on start, then again on 131 - each change. Their names are the value itself: 134 + If you're building a Lustre custom element, replace `lustre.component` with 135 + `off_topic.component`. The signature is identical except for the added 136 + `subscriptions` callback: 132 137 133 138 ```gleam 134 - off_topic.window_size(GotWindowSize) // dispatches immediately + on resize 135 - off_topic.color_scheme(GotColorScheme) // dispatches immediately + on change 136 - off_topic.online(GotOnlineStatus) // dispatches immediately + on change 139 + import off_topic 140 + 141 + pub fn register() { 142 + off_topic.component(init:, update:, subscriptions:, view:, options: []) 143 + } 137 144 ``` 138 145 139 - **Transient events** happen at a point in time. Their subscriptions only fire when 140 - the event occurs. Their names start with `on_`: 146 + Also swap your import of `lustre/component` for `off_topic/component`. It is a 147 + drop-in replacement — every builder function (`on_attribute_change`, `on_connect`, 148 + `on_disconnect`, and so on) is re-exported unchanged. 141 149 142 - ```gleam 143 - off_topic.on_click(UserClicked) 144 - off_topic.on_key_down(UserPressedKey) 145 - off_topic.on_pointer_move(MouseMoved) 150 + ```diff 151 + - import lustre/component 152 + + import off_topic/component 146 153 ``` 147 154 148 - ## Slowing things down 155 + **Subscriptions follow the client lifecycle.** off_topic starts subscriptions when 156 + the first client connects to the component and stops them when the last one 157 + disconnects. While no clients are connected, no subscriptions are running. This 158 + lifecycle is specific to `off_topic.component` — it does not apply when using 159 + `off_topic.application`. 149 160 150 - Some events fire very rapidly. Pointer-move events, for example, can arrive 151 - hundreds of times per second. off_topic lets you rate-limit any subscription with 152 - `throttle` or `delay`: 153 161 154 - ```gleam 155 - off_topic.on_pointer_move(MouseMoved) 156 - |> off_topic.throttle(wait: duration.milliseconds(50)) 157 - ``` 162 + Be aware that `on_disconnect` — and therefore subscription cleanup — is not 163 + guaranteed. If the browser closes the tab abruptly, the disconnect callback may 164 + never fire. 165 + 166 + ## How subscriptions are started and stopped 167 + 168 + After every `update`, off_topic calls `subscriptions` with the new model and 169 + compares the result to the previous one. 170 + 171 + Subscriptions are matched **by position** within their batch. The first child is 172 + compared to the first child from last time, the second to the second, and so on. 173 + This means the shape of your batch should stay stable across updates. 174 + 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, 175 + either as an active `every(…)` or as `none()`. 158 176 159 - `throttle` passes the first event through immediately, then drops the rest until 160 - `wait` has elapsed. `delay` holds the last event in a burst and dispatches it only 161 - once the subscription has been quiet for `wait` — useful for search-as-you-type: 177 + Once matched by position, a subscription is kept running if its **dependencies** 178 + haven't changed. Built-in subscriptions derive their dependencies from their own 179 + parameters — the duration, the message constructor, and so on. Change any of those 180 + and off_topic stops the old one and starts a fresh one. 181 + 182 + Sometimes a subscription's behaviour depends on model state that isn't captured in 183 + its own parameters. `watching` lets you declare those extra dependencies: 162 184 163 185 ```gleam 164 - off_topic.on_key_up(SearchQueryChanged) 165 - |> off_topic.delay(wait: duration.milliseconds(300)) 186 + off_topic.on_pointer_move(PointerMoved) 187 + |> off_topic.watching([off_topic.dep(model.dragging)]) 166 188 ``` 167 189 168 - You can combine both: `throttle` handles the running stream while `delay` catches 169 - the final value after the burst ends. 190 + This restarts the subscription whenever `model.dragging` changes. Without 191 + `watching`, the subscription's own parameters haven't changed, so off_topic has no 192 + reason to restart it. 170 193 171 194 ## Where next 172 195 ··· 174 197 function with its type signature and a short description. The guides cover the 175 198 topics this one skips: 176 199 177 - - [Subscriptions from scratch](./01-subscriptions-from-scratch.html) — build 178 - your own subscriptions with `from`, `element`, and `resource`. Understand how 179 - dependencies control restart behaviour and how the server-component path works. 200 + - [Subscriptions from scratch](./subscriptions-from-scratch.html) — build 201 + your own subscriptions with `from`, `element`, and `resource`. 180 202 181 - - [Listening for events](./02-listening-for-events.html) — the full set of built-in 182 - `on_*` event subscriptions and when to reach for `on(target, event, decoder)` 183 - directly to handle events off_topic doesn't cover out of the box. 184 - 185 - - [Commands](./03-commands.html) — the built-in commands for storage, scroll, 186 - and focus, how they work in server-component apps, and how to write your own. 187 - 188 - - [Extending the ot-client runtime](./04-extending-the-runtime.html) — how to 189 - register custom subscription and command handlers in `window.OffTopic`. 203 + - [Using off_topic with Server Components](./server-components.html) — how 204 + off_topic can be used with server-components and how it makes 205 + browser events available on the server.
+162
guides/server-components.md
··· 1 + # Server components 2 + 3 + off_topic works with Lustre server components — apps whose Gleam logic runs on 4 + the BEAM but whose UI lives in the browser. 5 + 6 + ## How it works 7 + 8 + When your app runs as a server component, Gleam runs on the server and the 9 + browser renders a `<lustre-server-component>` element. off_topic adds a second 10 + custom element — `ot-client-runtime` — alongside it. This element is the bridge: 11 + it receives subscription start and stop instructions from the server, runs the 12 + corresponding JavaScript handlers in the browser, and sends dispatched values 13 + back over the wire. 14 + 15 + Almost all built-in subscriptions work in server components. Each one has two 16 + paths internally: a direct browser path for SPAs, and a delegated path that runs 17 + through the client runtime when the app is on the BEAM. The only exceptions are 18 + the ones marked `JS` in the API reference (e.g. Websockets and Server-sent evnets) 19 + which only have a browser path. 20 + 21 + ## Setting up 22 + 23 + Use `off_topic.application` or `off_topic.component` as you normally would. 24 + On the server target, the `ot-client-runtime` element is injected into your 25 + `view` function automatically. You still need to load the client runtime script 26 + in the host page. Serve `priv/static/off-topic.mjs` (or the minified variant) 27 + from your web server and include it as a module script: 28 + 29 + ```html 30 + <script type="module" src="/off-topic.mjs"></script> 31 + ``` 32 + 33 + Alternatively, `off_topic.script()` returns the runtime as an inline `<script>` 34 + element — useful when you don't control the host page directly. 35 + 36 + The `subscriptions` callback works identically to a browser app. Subscriptions 37 + switch to their server path on their own: 38 + 39 + ```gleam 40 + fn subscriptions(model: Model) -> off_topic.Subscription(Msg) { 41 + off_topic.batch([ 42 + off_topic.page_state(PageStateChanged), 43 + off_topic.every(duration.seconds(1), False, Ticked), 44 + ]) 45 + } 46 + ``` 47 + 48 + 49 + ## Slowing things down 50 + 51 + Some events fire very rapidly. Pointer-move events, for example, can arrive 52 + hundreds of times per second. off_topic lets you rate-limit any subscription with 53 + `throttle` or `delay`: 54 + 55 + ```gleam 56 + off_topic.on_pointer_move(MouseMoved) 57 + |> off_topic.throttle(wait: duration.milliseconds(50)) 58 + ``` 59 + 60 + `throttle` passes the first event through immediately, then drops the rest until 61 + `wait` has elapsed. `delay` holds the last event in a burst and dispatches it only 62 + once the subscription has been quiet for `wait` — useful for search-as-you-type: 63 + 64 + ```gleam 65 + off_topic.on_key_up(SearchQueryChanged) 66 + |> off_topic.delay(wait: duration.milliseconds(300)) 67 + ``` 68 + 69 + You can combine both: `throttle` handles the running stream while `delay` catches 70 + the final value after the burst ends. 71 + 72 + 73 + ## Extending with custom subscriptions and commands 74 + 75 + The client runtime's job is to run JavaScript on behalf of the server. It does 76 + this through two registries on `window.OffTopic`: one for subscriptions (start 77 + a listener, return a cleanup) and one for commands (fire and forget). The server 78 + refers to handlers by name — `off_topic.remote` starts a named subscription, 79 + `off_topic.command` fires a named command. 80 + 81 + This is also how the built-in subscriptions work under the hood. Each one 82 + uses `lustre.is_browser()` to decide which path to take: in the browser it sets 83 + up the listener directly; on the server it calls `remote` with the name of the 84 + corresponding built-in JS handler. You can follow the same pattern to write 85 + subscriptions that work in both contexts — browser SPAs and server components — 86 + from a single Gleam function. When running in the browser, set up the listener 87 + yourself with `from`; when running on the server, delegate to a named handler 88 + you register in `window.OffTopic`. 89 + 90 + ```gleam 91 + pub fn geolocation(send: fn(Float, Float) -> msg) -> off_topic.Subscription(msg) { 92 + let decoder = { 93 + use lat <- decode.field("lat", decode.float) 94 + use lng <- decode.field("lng", decode.float) 95 + decode.success(send(lat, lng)) 96 + } 97 + off_topic.remote(name: "my-app/geolocation", with: [], run: decoder) 98 + } 99 + ``` 100 + 101 + The corresponding JavaScript handler starts the watch and returns the cleanup: 102 + 103 + ```javascript 104 + window.OffTopic.subscriptions["my-app/geolocation"] = (dispatch) => { 105 + const id = navigator.geolocation.watchPosition((pos) => { 106 + dispatch({ lat: pos.coords.latitude, lng: pos.coords.longitude }); 107 + }); 108 + return () => navigator.geolocation.clearWatch(id); 109 + }; 110 + ``` 111 + 112 + If you want the same subscription to also work in a browser SPA — without going 113 + through the bridge — use `lustre.is_browser()` to branch: return `remote` on the 114 + server and a direct `from` subscription in the browser. 115 + 116 + `off_topic.command` fires a one-shot JavaScript handler from an effect: 117 + 118 + ```gleam 119 + pub fn scroll_to(selector: String) -> Effect(msg) { 120 + off_topic.command("my-app/scroll-to", [json.string(selector)]) 121 + } 122 + ``` 123 + 124 + Register both in `window.OffTopic` in the host page: 125 + 126 + ```javascript 127 + window.OffTopic ??= {}; 128 + window.OffTopic.subscriptions ??= {}; 129 + window.OffTopic.commands ??= {}; 130 + 131 + window.OffTopic.subscriptions["my-app/media-query"] = (query, dispatch) => { 132 + const mq = window.matchMedia(query); 133 + const handler = () => dispatch(mq.matches); 134 + handler(); 135 + mq.addEventListener("change", handler); 136 + return () => mq.removeEventListener("change", handler); 137 + }; 138 + 139 + window.OffTopic.commands["my-app/scroll-to"] = (selector) => { 140 + document.querySelector(selector)?.scrollIntoView({ behavior: "smooth" }); 141 + }; 142 + ``` 143 + 144 + The `??=` guards let your setup code and the runtime initialise in any order. 145 + See [Extending the ot-client runtime](./04-extending-the-runtime.html) for the 146 + full handler API, including how to dispatch DOM events and use the host element. 147 + 148 + 149 + ## `including` 150 + 151 + When a subscription runs through the server-component bridge, the value passed 152 + to `dispatch` is serialised and sent over the wire. If you dispatch a raw 153 + browser event, `including` restricts which fields are forwarded: 154 + 155 + ```gleam 156 + off_topic.on(on: off_topic.Document, event: "keydown", run: my_decoder) 157 + |> off_topic.including(["key", "code", "ctrlKey"]) 158 + ``` 159 + 160 + `including` has no effect on client-side `from` or `element` subscriptions. 161 + The built-in `on_key_*` functions already call `including` with the correct 162 + fields internally.
+237
guides/subscriptions-from-scratch.md
··· 1 + # Subscriptions from scratch 2 + 3 + The convenience functions — `on_key_down`, `window_size`, `every` — are all 4 + built from two primitives: `from` and `element`. This guide explains how those 5 + primitives work and how to write your own subscriptions, including the FFI glue 6 + that connects Gleam to browser APIs. 7 + 8 + ## What a subscription is 9 + 10 + A `Subscription(message)` is a description that tells the runtime "while you're 11 + running, watch X and dispatch Y when it changes." It is not an active listener — 12 + the runtime starts and stops subscriptions for you as your `subscriptions` 13 + callback returns different values after each update. 14 + 15 + This is different from an `Effect(message)`: an effect is a one-shot operation 16 + that fires and completes; a subscription persists until it is removed from the 17 + subscription set. 18 + 19 + ## `from` 20 + 21 + `from` is the fundamental primitive, similar to `effect.from`. It takes a list 22 + of dependencies and a setup callback. The callback receives the same `dispatch` 23 + function as a Lustre `Effect`, but must additionally return a cleanup function. 24 + off_topic calls setup when the subscription starts and cleanup when it 25 + stops. This maps neatly onto the setup/teardown pattern of most browser APIs. 26 + 27 + ```gleam 28 + use dispatch <- off_topic.from(watching: [off_topic.dep("my-app/my-sub")]) 29 + let handle = set_up_something(fn(value) { dispatch(send(value)) }) 30 + fn() { tear_down_something(handle) } 31 + ``` 32 + 33 + 34 + ## Writing FFI 35 + 36 + `from` subscriptions almost always need FFI to reach browser APIs. In Gleam you 37 + declare external functions with `@external` and implement them in a `.mjs` file 38 + next to your source. 39 + 40 + Here is a complete document visibility subscription. First, the FFI declarations: 41 + 42 + ```gleam 43 + // src/my_app/browser.gleam 44 + import gleam/dynamic.{type Dynamic} 45 + 46 + @external(javascript, "./browser.mjs", "visibilityState") 47 + pub fn visibility_state() -> String 48 + 49 + @external(javascript, "./browser.mjs", "addVisibilityListener") 50 + pub fn add_visibility_listener(handler: fn(Dynamic) -> Nil) -> Nil 51 + 52 + @external(javascript, "./browser.mjs", "removeVisibilityListener") 53 + pub fn remove_visibility_listener(handler: fn(Dynamic) -> Nil) -> Nil 54 + ``` 55 + 56 + The JavaScript implementation alongside it: 57 + 58 + ```javascript 59 + // src/my_app/browser.mjs 60 + export function visibilityState() { 61 + return document.visibilityState; 62 + } 63 + 64 + export function addVisibilityListener(handler) { 65 + document.addEventListener("visibilitychange", handler); 66 + } 67 + 68 + export function removeVisibilityListener(handler) { 69 + document.removeEventListener("visibilitychange", handler); 70 + } 71 + ``` 72 + 73 + Then the subscription itself: 74 + 75 + ```gleam 76 + // src/my_app/subscriptions.gleam 77 + import my_app/browser 78 + import off_topic.{type Subscription} 79 + 80 + pub fn visibility(on_visible handle_visible: fn(Bool) -> msg) -> Subscription(msg) { 81 + use dispatch <- off_topic.from(watching: []) 82 + let handler = fn(_event) { 83 + dispatch(handle_visible(browser.visibility_state() == "visible")) 84 + } 85 + browser.add_visibility_listener(handler) 86 + fn() { browser.remove_visibility_listener(handler) } 87 + } 88 + ``` 89 + 90 + ## `element` 91 + 92 + Some browser APIs need a reference to a specific DOM element — 93 + `ResizeObserver`, `IntersectionObserver`, and `MutationObserver` all work this 94 + way. This is the job of `element`: the setup callback receives a second 95 + argument, the Lustre root element as a `Dynamic` value. The setup also runs as a 96 + `before_paint` effect, so the element is guaranteed to be in the DOM. 97 + 98 + ```gleam 99 + // FFI declarations in browser.gleam 100 + @external(javascript, "./browser.mjs", "newResizeObserver") 101 + fn new_resize_observer(callback: fn(Int, Int) -> Nil) -> Dynamic 102 + 103 + @external(javascript, "./browser.mjs", "observeElement") 104 + fn observe_element(observer: Dynamic, el: Dynamic) -> Nil 105 + 106 + @external(javascript, "./browser.mjs", "disconnectObserver") 107 + fn disconnect_observer(observer: Dynamic) -> Nil 108 + ``` 109 + 110 + ```javascript 111 + // browser.mjs 112 + export function newResizeObserver(callback) { 113 + return new ResizeObserver((entries) => { 114 + const { width, height } = entries[0].contentRect; 115 + callback(Math.round(width), Math.round(height)); 116 + }); 117 + } 118 + 119 + export function observeElement(observer, el) { 120 + observer.observe(el); 121 + } 122 + 123 + export function disconnectObserver(observer) { 124 + observer.disconnect(); 125 + } 126 + ``` 127 + 128 + ```gleam 129 + pub fn element_size(send: fn(Int, Int) -> msg) -> off_topic.Subscription(msg) { 130 + use dispatch, root <- off_topic.element(watching: []) 131 + let observer = new_resize_observer(fn(w, h) { dispatch(send(w, h)) }) 132 + observe_element(observer, root) 133 + fn() { disconnect_observer(observer) } 134 + } 135 + ``` 136 + 137 + `element` is JS-only — it has no server-component path. If you need a 138 + subscription that works in both a browser SPA and a server component, use `from` 139 + or `element` on the browser side paired with `remote` on the server side. See 140 + [Server components](./server-components.html) for this pattern. 141 + 142 + ## Dependencies and `dep` 143 + 144 + Every subscription carries a dependency list. off_topic keeps a subscription 145 + running when the dependencies haven't changed between updates, and restarts it 146 + — cleanup, then setup — when any dependency changes. 147 + 148 + `dep(value)` wraps any Gleam value as a `Dependency`. Values are compared 149 + structurally: equal values are the same dependency, different values are not. 150 + 151 + ```gleam 152 + // Restart whenever model.user_id changes 153 + use dispatch <- off_topic.from(watching: [off_topic.dep(model.user_id)]) 154 + let stream = open_sse("/users/" <> model.user_id <> "/events") 155 + fn() { close_sse(stream) } 156 + ``` 157 + 158 + It's often a good idea to always pass _something_ - like a stable string - 159 + as a dependency, even when the subscription doesn't depend on any model values. 160 + This makes sure that swapping an `online` subscription for a `here` subscription 161 + for example properly starts and stops the respective listeners. 162 + 163 + ```gleam 164 + use dispatch <- off_topic.from(watching: [off_topic.dep("my-app/my-subscription")]) 165 + ``` 166 + 167 + Pass an empty list to run once and never restart: 168 + 169 + ```gleam 170 + use dispatch <- off_topic.from(watching: []) 171 + ``` 172 + 173 + ## `watching` 174 + 175 + `watching` adds extra dependencies to an already-built subscription without 176 + changing what it listens to. It's useful when a subscription needs to restart 177 + based on model state that isn't one of its own parameters: 178 + 179 + ```gleam 180 + off_topic.on_pointer_move(PointerMoved) 181 + |> off_topic.watching([off_topic.dep(model.dragging)]) 182 + ``` 183 + 184 + ## `resource` and `watch` 185 + 186 + `resource` is a convenience wrapper around `from` for the acquire/release 187 + pattern. `setup` receives `dispatch` and returns the resource; `teardown` 188 + receives the resource and cleans it up: 189 + 190 + ```gleam 191 + off_topic.resource( 192 + watching: [off_topic.dep(url)], 193 + setup: fn(dispatch) { open_event_source(url, fn(data) { dispatch(send(data)) }) }, 194 + teardown: fn(es) { close_event_source(es) }, 195 + ) 196 + ``` 197 + 198 + `watch` is for side effects that don't dispatch messages — it runs its callback 199 + once on start and again whenever dependencies change: 200 + 201 + ```gleam 202 + off_topic.watch( 203 + watching: [off_topic.dep(model.route)], 204 + run: fn() { log("navigated to " <> model.route) }, 205 + ) 206 + ``` 207 + 208 + ## Subscription patterns 209 + 210 + Most subscriptions you write fall into one of three shapes. 211 + 212 + ### Event subscriptions 213 + 214 + Listen for a browser event and dispatch a message each time it fires. This is what 215 + I imagine most people think of when they hear "subscription" - the FFI 216 + wraps a pair of `addEventListener` / `removeEventListener` calls, setup 217 + attaches the handler, and the cleanup removes it. 218 + 219 + ### Observer subscriptions 220 + 221 + It's often more useful though to instead immediately read some value from the DOM, 222 + sending it back to the app , before _also_ setting up events to listen for changes 223 + to that value. 224 + 225 + This way, we're no longer just listening to a value, we're synchronising some 226 + external state with our app. This is in my opinion the most useful way to 227 + work with subscriptions, and many of the built-in ones follow this pattern: 228 + `window_size` gives you the current window size immediately _and also_ subscribes 229 + to the `resize` event etc. 230 + 231 + ### Command subscriptions 232 + 233 + React to model state with a side effect without dispatching any messages — 234 + updating the document title, toggling a CSS class, managing focus. Setup applies 235 + the effect, and cleanup reverses it. Use `from` and ignore the `dispatch` 236 + argument. `off_topic.watch` is a shorthand for the case where there's nothing to 237 + reverse on cleanup.
+2 -7
src/off_topic.gleam
··· 1663 1663 /// 1664 1664 /// Dispatches `True` when online and `False` when offline. 1665 1665 pub fn online(send message: fn(Bool) -> msg) -> Subscription(msg) { 1666 - case lustre.is_browser() { 1667 - True -> { 1668 - use dispatch <- from(watching: [dep("off-topic/online")]) 1669 - subscriptions.online(fn(b) { dispatch(message(b)) }) 1670 - } 1671 - False -> remote("off-topic/online", [], decode.map(decode.bool, message)) 1672 - } 1666 + use dispatch <- from(watching: [dep("off-topic/online")]) 1667 + subscriptions.online(fn(b) { dispatch(message(b)) }) 1673 1668 } 1674 1669 1675 1670 /// Subscribe to a Server-Sent Events stream. <small>JS</small>
-1
src/off_topic/internal/component.mjs
··· 195 195 "off-topic/media-query": Subscriptions.media_query, 196 196 "off-topic/window-hover": Subscriptions.window_hover, 197 197 "off-topic/here": Subscriptions.here, 198 - "off-topic/online": Subscriptions.online, 199 198 "off-topic/window-size": Subscriptions.window_size, 200 199 "off-topic/window-scroll": Subscriptions.window_scroll, 201 200 "off-topic/screen-orientation": Subscriptions.screen_orientation,