catalyst/docs/_guide/patterns.md

5.5 KiB

chapter subtitle
11 Patterns

An aim of Catalyst is to be as light weight as possible, and so we often avoid including helper functions for otherwise fine code. We also want to keep Catalyst focussed, and so where some helper functions might be reasonable, we recommend judicious use of other small libraries.

Here are a few common patterns which we've avoided introducing into the Catalyst code base, and instead encourage you to take the example code and run with that:

Debouncing or Throttling events

Often times you'll want to do something computationally intensive (or network intensive) based on a user event. It's worth throttling the amount of times a function can be called for these events, to prevent saturation of the CPU or network. For this we can use the "debounce" or "throttle" patterns. We recommend using the @github/mini-throttle library for this, which provides throttling decorators for methods:

import {controller} from '@github/catalyst'
import {debounce} from '@github/mini-throttle/decorators'

@controller
class FuzzySearchElement extends HTMLElement {

  // Adding `@debounce(100)` here means this method will only be called once in a 100ms period.
  @debounce(100)
  search(event: Event) {
    const value = event.currentTarget.value
    // This function is very computationally intensive, so we should run it as little as possible
    this.filterAllItemsWithValue(value)
  }

}

Alternatively, if you'd like more precise control over the exact way debouncing happens (for example you'd like to make the debounce timeout dynamic, or sometimes call without debouncing), you can have two methods following the pattern of foo/fooNow or foo/fooSync, where the non-suffixed method dispatches asynchronously to the Now/Sync suffixed method, a little like this:

import {controller} from '@github/catalyst'

@controller
class FuzzySearchElement extends HTMLElement {

  #searchAnimationFrame = 0
  search(event: Event) {
    clearAnimationFrame(this.#searchAnimationFrame)
    this.#searchAnimationFrame = requestAnimationFrame(() => this.searchNow(event: Event))
  }
  
  searchNow(event: Event) {
    const value = event.currentTarget.value
    // This function is very computationally intensive, so we should run it as little as possible
    this.filterAllItemsWithValue(value)
  }

}

Aborting Network Requests

When making network requests using fetch, based on user input, you can cancel old requests as new ones come in. This is useful for performance as well as UI responsiveness, as old requests that aren't cancelled might complete later than newer ones, and causing the UI to jump around. Aborting network requests requires you to use AbortController (a web platform feature).

@controller
class RemoveSearchElement extends HTMLElement {

  #remoteSearchController: AbortController|null

  async search(event: Event) {
    // Abort the old Request
    this.#remoteSearchController?.abort()

    // To start making a new request, construct an AbortController
    const {signal} = (this.#remoteSearchController = new AbortController())

    try {
      const res = await fetch(myUrl, {signal})

      // ... Add logic here with the completed network response
    } catch (e) {

      // ... Add logic here if you need to report a failed network request.
      // Do not rethrow for network errors!

    }

    if (signal.aborted) {
      // Here you can add logic for if the request was cancelled, but
      // usually what you want to do is just return early to avoid
      // cleaning up the loading UI (bear in mind if the request is
      // cancelled then another one will be in its place).
      return
    }

    // ... Add cleanup logic here, such as removing `loading` classes.

  }
}

Registering global or many event listeners

Generally speaking, you'll want to use ["Actions"]({{ site.baseurl }}/guide/actions) to register event listeners with your Controller, but Actions only work for components nested within your Controller. It may also be necessary to listen for events on the Document, Window, or across well-known adjacent elements. We can manually call addEventListener for these types, including during the connectedCallback phase. Cleanup for addEventListener can be a bit error prone, but AbortController can be useful here to pass a signal that the element is cleaning up. AbortControllers should be created once per connectedCallback, as they are not re-usable, while Controllers can be reused.

@controller
class UnsavedChangesElement extends HTMLElement {

  #eventAbortController: AbortController|null = null

  connectedCallback(event: Event) {
    // Create the new AbortController and get the new signal
    const {signal} = (this.#eventAbortController = new AbortController())
    
    // You can `signal` as an option to any `addEventListener` call:
    window.addEventListener('hashchange', this, { signal })
    window.addEventListener('blur', this, { signal })
    window.addEventListener('popstate', this, { signal })
    window.addEventListener('pagehide', this, { signal })
  }
  
  disconnectedCallback() {
    // This will clean up any `addEventListener` calls which were given the `signal`
    this.#eventAbortController?.abort()
  }
  
  handleEvent(event) {
    // `handleEvent` will be called when each one of the event listeners
    // defined in `connectedCallback` is dispatched.
  }
}