Skip to content

Commit a300510

Browse files
keithamuskoddsson
andcommitted
add signals and cancellation section
Co-authored-by: Kristján Oddsson <[email protected]>
1 parent e74d604 commit a300510

File tree

3 files changed

+378
-1
lines changed

3 files changed

+378
-1
lines changed

_data/groups.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"learn": ["Getting Started", "JavaScript", "Components"],
2+
"learn": ["Getting Started", "JavaScript", "Components", "Techniques"],
33
"tutorials": ["Mastodon Toot Embed"]
44
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
---
2+
title: Using Signals and Cancellation
3+
order: 2
4+
---
5+
6+
Long running tasks, asynchronous tasks, and operations that have a _set up_ and _tear down_ phase, can make use of a
7+
concept called _cancellation_. If you've used other programming languages you might be familiar with objects like
8+
`CancellationToken` (or [Go's `context`](https://pkg.go.dev/context)) - JavaScript's equivalent is `AbortSignal` which
9+
can be operated with an `AbortController`.
10+
11+
The `AbortController` & `AbortSignal` APIs can help manage operational "life time". Some examples:
12+
13+
- Long running or asynchronous tasks, for example timers or network fetches
14+
- Overlapping operations, for example cancelling in-flight network fetches to replace them with newer ones
15+
- Set up and tear down, for example a Web Components `connectedCallback` and `disconnectedCallback`
16+
17+
## Signal Controller Pattern
18+
19+
_Signals_ get given to APIs so they know when to abort. Signals are created by _controllers_ (`new AbortSignal()` will
20+
throw an error). _Controllers_ allow you to make the decision of when a Signal changes. Creating an `AbortController`
21+
will also create a new _Signal_ accessible via `.signal`. Code that has access to the _controller_ can determine when it
22+
should be aborted (by calling `.abort()`), while code that has access to the _signal_ can be notified of the abort. To
23+
make a new `AbortController`, call `new AbortContoller()`. The constructor takes no arguments.
24+
25+
A _signals_ `.aborted` property will be `true` if the signal has been aborted - you can periodically check that to stop
26+
any work that is about to be done. `AbortSignal` is also an `EventTarget` - it emits an `abort` event which you can
27+
listen to and invoke your tear down.
28+
29+
You can also create some basic _controller-free signals_ that follow some common patterns. For example
30+
`AbortSignal.timeout(1000)` will create a _signal_ that aborts after 1000ms. These _controller-free signals_ cannot be
31+
manually aborted. However, you can _combine_ controller-free and controllable signals with
32+
`AbortSignal.any([...signals])`.
33+
34+
## Using Signals internally manage your private APIs
35+
36+
_Signals_ can be used to manage internal state that you might have. You can create an `AbortController` as part of your
37+
private state, and make use of _signals_ to control behavior. Consumers of your component won't pass these signals to
38+
you, instead you can use them to track a tasks state internally.
39+
40+
A component with `start()` and `stop()` functions can make the `stop()` function abort the controller, and the `start()`
41+
function create the controller, while checking if the signal has been aborted during an asynchronous loop like so:
42+
43+
```js
44+
class StopWatchElement extends HTMLElement {
45+
static define(tag = "stop-watch") {
46+
customElements.define(tag, this)
47+
}
48+
49+
shadowRoot = this.attachShadow({ mode: "open" })
50+
51+
// Set up the class field, but set it to null for now
52+
#startStopController = null
53+
54+
async start() {
55+
// Stop the current task if there was one
56+
this.stop()
57+
58+
// Create a new internal controller, and get the signal
59+
this.#startStopController = new AbortController()
60+
const signal = this.#startStopController.signal
61+
62+
// Loop until `signal.aborted` is `true`.
63+
while (!signal.aborted) {
64+
// Automatically stop when this element is disconnected
65+
if (!this.isConnected) return
66+
67+
const milliseconds = Date.now() - this.#start
68+
const minutes = String(Math.floor(milliseconds / (1000 * 60))).padStart(2, "0")
69+
const seconds = String(Math.floor((milliseconds / 1000) % 60)).padStart(2, "0")
70+
const hundredths = String(Math.floor((milliseconds % 1000) / 10)).padStart(2, "0")
71+
this.shadowRoot.replaceChildren(`${minutes}:${seconds}:${hundredths}`)
72+
73+
// Schedule next update by awaiting an animation frame
74+
await new Promise((resolve) => requestAnimationFrame(resolve))
75+
}
76+
}
77+
78+
stop() {
79+
// Stop aborts the startStopController if it exists
80+
this.#startStopController?.abort()
81+
}
82+
}
83+
```
84+
85+
## Using Signals in your own public APIs
86+
87+
If you can use a signal as part of your internal state, it might be simpler to provide it as part of the public API. If
88+
you are considering using _signals_ in a public API, it's a good idea to make them an optional part of your API as they
89+
won't always be _needed_.
90+
91+
A component using _signals_ no longer needs separate start & stop methods, instead combining into one and relying on the
92+
signal to know when to stop. This can often simplify code as there is no need to track state across different methods.
93+
94+
```js
95+
class StopWatchElement extends HTMLElement {
96+
static define(tag = "stop-watch") {
97+
customElements.define(tag, this)
98+
}
99+
100+
shadowRoot = this.attachShadow({ mode: "open" })
101+
102+
async start({ signal } = {}) {
103+
// Loop until `signal.aborted` is `true`.
104+
// If `signal` doesn't exist, then loop forever.
105+
// Uses the optional chaining operator to safely check if signal exists
106+
while (!signal?.aborted) {
107+
// Automatically stop when this element is disconnected
108+
if (!this.isConnected) return
109+
110+
const milliseconds = Date.now() - this.#start
111+
const minutes = String(Math.floor(milliseconds / (1000 * 60))).padStart(2, "0")
112+
const seconds = String(Math.floor((milliseconds / 1000) % 60)).padStart(2, "0")
113+
const hundredths = String(Math.floor((milliseconds % 1000) / 10)).padStart(2, "0")
114+
this.shadowRoot.replaceChildren(`${minutes}:${seconds}:${hundredths}`)
115+
116+
// Schedule next update by awaiting an animation frame
117+
await new Promise((resolve) => requestAnimationFrame(resolve))
118+
}
119+
}
120+
}
121+
```
122+
123+
## Combining multiple Signals
124+
125+
It's possible to combine multiple sources of signals - for example combining internal and external signals to allow for
126+
multiple flavors of API. Two or more signals can be combined into one using `AbortSignal.any()`, which creates a _new
127+
signal_ that aborts when any of the given _signals_ abort. It's similar to `Promise.any()`, but for Signals.
128+
129+
A component can provide the more traditional `start()` and `stop()` APIs, as well allowing signals to be passed via
130+
`start({ signal })`. Making use of internal and external signals, with `AbortSignal.any()`:
131+
132+
```js
133+
class StopWatchElement extends HTMLElement {
134+
static define(tag = "stop-watch") {
135+
customElements.define(tag, this)
136+
}
137+
138+
shadowRoot = this.attachShadow({ mode: "open" })
139+
140+
#startStopController = null
141+
142+
async start({ signal } = {}) {
143+
// Stop the current task if there was one
144+
this.stop()
145+
146+
// Create a new internal controller
147+
this.#startStopController = new AbortController()
148+
// Collect all valid signals
149+
const signals = [this.#startStopController.signal, signal].filter((s) => s)
150+
151+
const signal = AbortSignal.any(signals)
152+
153+
// Loop until `signal.aborted` is `true`.
154+
while (!signal.aborted) {
155+
// Automatically stop when this element is disconnected
156+
if (!this.isConnected) return
157+
158+
const milliseconds = Date.now() - this.#start
159+
const minutes = String(Math.floor(milliseconds / (1000 * 60))).padStart(2, "0")
160+
const seconds = String(Math.floor((milliseconds / 1000) % 60)).padStart(2, "0")
161+
const hundredths = String(Math.floor((milliseconds % 1000) / 10)).padStart(2, "0")
162+
this.shadowRoot.replaceChildren(`${minutes}:${seconds}:${hundredths}`)
163+
164+
// Schedule next update by awaiting an animation frame
165+
await new Promise((resolve) => requestAnimationFrame(resolve))
166+
}
167+
}
168+
169+
stop() {
170+
this.#startStopController?.abort()
171+
}
172+
}
173+
```
174+
175+
### Using Signals to clean up `disconnectedCallback()`
176+
177+
_Web Components_ that use the `connectedCallback()` lifecycle hook to set things up typically want to tear down those
178+
same things in the `disconnectedCallback()`, but this can sometimes get a little unwieldy. Instead of mirroring
179+
everything in `disconnectedCallback()`, using an `AbortController` can reduce `disconnectedCallback()` down to one line
180+
of code. APIs called in `connectedCallback` will get given the _signal_, and `disconnectedCallback()` only calls
181+
`abort()`.
182+
183+
APIs like `addEventListener` accept a `signal` option. When an _Event Listeners_ _signal_ is _aborted_, the _event
184+
listener_ will be removed (just like calling `removeEventListener`).
185+
186+
```js
187+
class StopWatchElement extends HTMLElement {
188+
static define(tag = "stop-watch") {
189+
customElements.define(tag, this)
190+
}
191+
192+
// Set up the class field, but set it to null for now
193+
#connectedController = null
194+
195+
connectedCallback() {
196+
// Make a new AbortController and extract the `signal` property
197+
const { signal } = (this.#connectedController = new AbortController())
198+
199+
// Pass the signal to addEventListener
200+
this.ownerDocument.addEventListener("keypress", this, { signal })
201+
this.ownerDocument.addEventListener("mouseenter", this, { signal })
202+
this.ownerDocument.addEventListener("mouseleave", this, { signal })
203+
}
204+
205+
disconnectedCallback() {
206+
// All cleanup happens with this one line
207+
this.#connectedController?.abort()
208+
209+
// No need to do any of this:
210+
// this.ownerDocument.removeEventListener("keypress", this)
211+
// this.ownerDocument.removeEventListener("mouseenter", this)
212+
// this.ownerDocument.removeEventListener("mouseleave", this)
213+
}
214+
}
215+
```
216+
217+
### Using signals to cancel old requests
218+
219+
A common task that components might do is turn a user action into a network fetch. For example a search input might
220+
query the database every time a character is pressed. If the user types into the input fast enough, old network requests
221+
might stay _in-flight_, saturating the network and delaying newer requests from coming in, making the component feel
222+
sluggish. A good way to combat this is to cancel stale requests by using signals:
223+
224+
```js
225+
class SearchInputElement extends HTMLInputElement {
226+
static define(tag = "search-input") {
227+
customElements.define(tag, this, { extends: "input" })
228+
}
229+
230+
src = new URL("/search")
231+
232+
connectedCallback() {
233+
this.addEventListener("input", this)
234+
}
235+
236+
// Set up the class field, but set it to null for now
237+
#fetchController = null
238+
239+
async handleEvent() {
240+
// Abort the old fetch, if the controller exists
241+
this.#fetchController?.abort()
242+
243+
// Create a new one and extract the signal
244+
const { signal } = (this.#fetchController = new AbortContoller())
245+
246+
const src = new URL(this.src)
247+
src.searchParams.add("q", this.value)
248+
249+
// Perform the fetch, make sure to pass it the signal so it can be aborted
250+
try {
251+
const res = await fetch(src, { signal })
252+
} catch (error) {
253+
// An aborted network fetch will throw, so we should return early
254+
if (signal.aborted) return
255+
256+
throw error
257+
}
258+
259+
if (res.ok) {
260+
this.list.append(await res.text())
261+
}
262+
}
263+
}
264+
```
265+
266+
### Using signals to debounce or throttle methods
267+
268+
_Debouncing_ or _Throttling_ are techniques whereby you delay an action for a fixed time period, discarding any events
269+
between them. These techniques are useful within UI as a way to delay an expensive operation like a network fetch or
270+
something CPU intensive like sorting many items. Consider a list filter input, that shows and hides thousands of items
271+
depending on if they match the text input.
272+
273+
As debouncing & throttling are based on time, an `AbortController` isn't needed. Timeouts are such a common pattern that
274+
`AbortSignal.timeout()` can be used as a quick way to create a signal that will abort after some time has passed.
275+
276+
Throttling and debouncing are variations on the same broader concept of limiting the times an action gets executed. If
277+
you're calling a function 10 times in a row, at 50ms intervals, a throttle of 100ms would ensure that the action is only
278+
run every 100ms, whereas a debounce would defer each call until there hadn't been one for more 100ms or more. To
279+
illustrate this concept we can look at a timeline:
280+
281+
```
282+
|-----|-----|-----|-----|-----|-----|-----|-----|
283+
Source 50 100 200 250 350 400
284+
throttle(100) 100 200 350
285+
debounce(100) 100 250 400
286+
```
287+
288+
You can think of throttling as disregarding some calls but acting on others, whereas debounce waits for quiet periods.
289+
To throttle a method, you'll create the signal every time it is aborted, and execute the behavior when the timer has
290+
aborted:
291+
292+
```js
293+
class ListFilterInputElement extends HTMLInputElement {
294+
static define(tag = "list-filter-input") {
295+
customElements.define(tag, this, { extends: "input" })
296+
}
297+
298+
connectedCallback() {
299+
this.addEventListener("input", this)
300+
}
301+
302+
timeout = 100
303+
304+
#throttleSignal = null
305+
306+
handleEvent() {
307+
// Don't do anything if the timer has yet to time out
308+
if (this.#throttleSignal && !this.#throttleSignal.aborted) return
309+
310+
// The time has been aborted, so make a new timer for next time
311+
this.#throttleSignal = AbortSignal.timeout(this.timeout)
312+
313+
// Execute the action
314+
this.filer()
315+
}
316+
317+
filter() {
318+
for (const el of this.list.children) {
319+
el.hidden = el.textContent.includes(this.input)
320+
}
321+
}
322+
}
323+
```
324+
325+
_Debouncing_ would add a **delay** and so needs to act on the timeout happening, not some time after. Using the
326+
`aborted` event can queue up work for when the timer ends:
327+
328+
```js
329+
class ListFilterInputElement extends HTMLInputElement {
330+
static define(tag = "list-filter-input") {
331+
customElements.define(tag, this, { extends: "input" })
332+
}
333+
334+
connectedCallback() {
335+
this.addEventListener("input", this)
336+
}
337+
338+
timeout = 100
339+
340+
#debounceSignal = null
341+
342+
async handleEvent() {
343+
// Don't do anything if the timer has run out
344+
if (this.#debounceSignal?.aborted) return
345+
346+
// Renew the signal
347+
this.#debounceSignal = AbortSignal.timeout(this.timeout)
348+
349+
// Schedule work for after debouncement
350+
this.#debounceSignal.addEventListener(
351+
"abort",
352+
(event) => {
353+
// Check to see that new work hasn't been scheduled
354+
if (event.target === this.#debounceSignal) {
355+
// Clear out the signal so new work can be schedule
356+
this.#debounceSignal = null
357+
358+
// Execute the action
359+
this.filter()
360+
}
361+
},
362+
{ once: true }
363+
)
364+
}
365+
366+
filter() {
367+
for (const el of this.list.children) {
368+
el.hidden = el.textContent.includes(this.input)
369+
}
370+
}
371+
}
372+
```
373+

learn/techniques/techniques.json

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"tags": ["learn"],
3+
"group": "Techniques"
4+
}

0 commit comments

Comments
 (0)