DEV Community

Cover image for Supercharge your Stimulus controllers with Custom APIs
Marco Roth
Marco Roth

Posted on • Originally published at marcoroth.dev

Supercharge your Stimulus controllers with Custom APIs

Since its initial release in 2017, Stimulus has undergone minimal breaking changes and has been known for its stability. Even a Stimulus controller written for version 1.0.0 still works flawlessly with the latest 3.2.1 release, using the same syntax.

Considering the fast-paced world of JavaScript, this stability might seem unexpected. While Stimulus has added new features over the years, its primary strength lies in the various APIs introduced with each new release.

  • Stimulus 1.0.0: Targets API
  • Stimulus 2.0.0: Values API and the CSS classes API
  • Stimulus 3.2.0: Outlets API

While also other features were added in these releases, it's the Stimulus APIs that truly make Stimulus and the releases special.

In this article, we will explore the process of creating a new Stimulus API that can be used in any Stimulus Controller.

The Stimulus Elements API

Most Stimulus controllers should ideally be designed to be used on multiple DOM elements. However, there are also cases where we need to reference elements outside of the controller's scope or cases where you design a controller which is meant to control a specific element on the page.

The use of the Targets and/or Outlets API also might seem logical for these cases, but the Elements API might better suited if ...

  • ... you either can't control the elements you want to reference and/or can't define them as targets
  • ... they are outside of the controller scope and you don't need them to be full-blown outlets
  • ... or the controller specifically needs to control a special element on the page, independent from it's own controller element and it's children

A common pattern emerges in such situations: defining a get function on the controller with names like [name]Element or [name]Elements. These functions return a single element or a set of elements using document.querySelector(...) or document.querySelectorAll(...) respectively.

import { Controller } from "@hotwired/stimulus"
import tippy from "tippy.js"

export default class extends Controller {
  connect() {
    this.backdropElement.classList.remove("hidden")
    this.itemElements.forEach(element => ...)
    this.tippyElements.forEach(element => tippy(element))
  }

  // ...

  get backdropElement() {
    return document.querySelector("#backdrop")
  }

  get itemElements() {
    return document.querySelectorAll(".item")
  }

  get tippyElements() {
    return document.querySelectorAll("[data-tippy]")
  }
}
Enter fullscreen mode Exit fullscreen mode

To avoid repetition and improve code readability, we will build an API that abstracts this pattern into a Stimulus API, similar to the Targets or Outlets API.

Creating the Elements API

Stimulus internally uses so-called Blessings, which enhance the Controller class with new functionality. Each API is a separate Blessing, maintaining a modular design.

The Stimulus Controller class has a static blessings Array which holds all of the Blessings the controller should be blessed with.

In Stimulus 3.2, the blessings array contains:

// @hotwired/stimulus - src/core/controller.ts

export class Controller {
  static blessings = [
    ClassPropertiesBlessing,
    TargetPropertiesBlessing,
    ValuePropertiesBlessing,
    OutletPropertiesBlessing,
  ]

  // ...
}
Enter fullscreen mode Exit fullscreen mode

With that knowledge we can create our own ElementPropertiesBlessing and extend Stimulus with our Elements API by adding it to the blessings array.

Proposed API

Inspired by other Stimulus APIs, we'll declare a static property called elements, defining the elements we want to reference along with their CSS selectors.

import { Controller } from "@hotwired/stimulus"
import tippy from "tippy.js"

export default class extends Controller {
  static elements = {
    backdrop: "#backdrop",
    item: ".item",
    tippy: "[data-tippy]"
  }

  connect() {
    this.backdropElement.classList.remove("hidden")
    this.itemElements.forEach(element => ...)
    this.tippyElements.forEach(element => tippy(element))
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

By calling [name]Element or [name]Elements, the API will determine whether to use document.querySelector(...) or document.querySelectorAll(...), streamlining the process.

Implementing the ElementPropertiesBlessing

Let's dive into creating the ElementPropertiesBlessing function that will power our Elements API. We'll create a new file called element_properties.js exporting the ElementPropertiesBlessing constant:

// app/javascript/element_properties.js

export function ElementPropertiesBlessing(constructor) {
  const properties = {}

  return properties
}
Enter fullscreen mode Exit fullscreen mode

The ElementPropertiesBlessing function takes the constructor (controller) as an argument. Inside this function, we initialize an empty properties object to store the properties which should get added to the constructor.

In context of our Elements API we want properties to contain the functions for our [name]Element and [name]Elements getters, which should look something like in the end:

{
  'backdropElement': {
    get() {
      return document.querySelector(...)
    }
  },
  'backdropElements': {
    get() {
      return document.querySelectorAll(...)
    }
  },
  'itemElement':   { get() { /* ... */ } },
  'itemElements':  { get() { /* ... */ } },
  'tippyElement':  { get() { /* ... */ ) },
  'tippyElements': { get() { /* ... */ } }
}
Enter fullscreen mode Exit fullscreen mode

In order to construct that object we want to read the static elements property of the controller.

Stimulus (privately) exposes two functions we can use for this. Depending on the structure of the definition we can either use readInheritableStaticArrayValues() for an Array structure (like the Targets API) or readInheritableStaticObjectPairs() for an Object structure (like the Values API).

Note: About using privately exposed Stimulus functions:

The next version of Stimulus makes it easier to access parts of the private API thanks to my pull request.

If you want to make use of that today, you can install the latest dev-build using:

  yarn add @hotwired/stimulus@https://github.com/hotwired/dev-builds/archive/@hotwired/stimulus/7b810ec.tar.gz
Enter fullscreen mode Exit fullscreen mode

Otherwise, you need to wait for the upcoming Stimulus 3.3 release.


Since our Elements API uses an object to define the elements and selectors we want to use the readInheritableStaticObjectPairs() function.

import { readInheritableStaticObjectPairs } from "@hotwired/stimulus/dist/core/inheritable_statics"

export function ElementPropertiesBlessing(constructor) {
  const properties = {}
  const definitions = readInheritableStaticObjectPairs(constructor, "elements")

  return properties
}
Enter fullscreen mode Exit fullscreen mode

The definitions variable now holds an array of definitions, which looks like this:

[
  ["backdrop", "#backdrop"],
  ["item", ".item"],
  ["tippy", "[data-tippy]"]
]
Enter fullscreen mode Exit fullscreen mode

Let's define a propertiesForElementDefinition() function, which generates the corresponding properties for each element definition.

import { readInheritableStaticObjectPairs } from "@hotwired/stimulus/dist/core/inheritable_statics"
import { namespaceCamelize } from "@hotwired/stimulus/dist/core/string_helpers"

export function ElementPropertiesBlessing(constructor) {
  const properties = {}
  const definitions = readInheritableStaticObjectPairs(constructor, "elements")

  return properties
}

function propertiesForElementDefinition(definition) {
  const [name, selector] = definition
  const camelizedName = namespaceCamelize(name)

  return {
    [`${camelizedName}Element`]: {
      get() {
        return document.querySelector(selector)
      }
    },

    [`${camelizedName}Elements`]: {
      get() {
        return document.querySelectorAll(selector)
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In this function, we extract the name and selector from the definition array. Then, using namespaceCamelize(), we create camel-cased versions of the element names.

The propertiesForElementDefinition() function returns an object containing two properties for each element definition: [name]Element and [name]Elements. The former returns a single element using document.querySelector(...), while the latter returns a NodeList using document.querySelectorAll(...).

For each element definition in the definitions array, we want to call the propertiesForElementDefinition() function to create the corresponding properties and merge them into the properties object using Object.assign(). Finally, we return the properties object containing all the element properties for the given controller.

export function ElementPropertiesBlessing(constructor) {
  const properties = {}
  const definitions = readInheritableStaticObjectPairs(constructor, "elements")

  definitions.forEach(definition => {
    Object.assign(properties, propertiesForElementDefinition(definition))
  })

  return properties
}
Enter fullscreen mode Exit fullscreen mode

By implementing the ElementPropertiesBlessing, we have constructed the backbone of our Elements API. This blessing will dynamically add the necessary properties to each controller instance, enabling easy access to the referenced DOM elements defined in the static elements property.

Installing the API

To apply our new API, we need to tell Stimulus about it when starting the application. In Rails apps this is typically done in app/javascript/controllers/application.js.

We want to add our ElementPropertiesBlessing to the blessings array on the Controller class. By importing both Controller and ElementPropertiesBlessing we can push the function into the blessings array and Stimulus will add the properties to each new controller instance.

// app/javascript/controllers/application.js

import { Application, Controller } from "@hotwired/stimulus"
import { ElementPropertiesBlessing } from "../element_properties"

Controller.blessings.push(ElementPropertiesBlessing)

const application = Application.start()

// Configure Stimulus development experience
application.warnings = true
application.debug = false
window.Stimulus = application

export { application }
Enter fullscreen mode Exit fullscreen mode

With this, the Elements API is now available in every Stimulus controller within our application.

More ideas

We could extend the API even more and define a has[Name]Element property which would check if an element exists for a given selector, similar to the Targets and Outlets API.

We could also think about extending the API to accept the selectors for the elements on the controller element using data attributes in the format of data-[identifier]-[element]-element. This could allow to defined selectors for the elements to be overridden:

<div
  data-controller="test"
  data-test-backdrop-element=".backdrop"
  data-test-item-element=".item:not([data-disabled])"
  data-test-tippy-element="span.tippy"
></div>
Enter fullscreen mode Exit fullscreen mode

Conclusion

We successfully built a new Stimulus "Elements API," abstracting the pattern of referencing elements in controllers using CSS selectors. By leveraging Stimulus' modular architecture and the concept of Blessings, we extended the Controller class with our own API.

The Elements API enhances code readability by encapsulating the element lookup logic and reducing the need for repetitive code in controllers. It improves productivity, making it more elegant and efficient to work with DOM elements in various use cases.

Part of the reason for this post was to demonstrate that not every new API needs to ship with Stimulus itself to be useful in applications. Shipping custom APIs as application-specific code allows us build APIs to our specific needs without polluting the upstream framework with APIs not everyone might benefit from.

With that being said, I could also see a future where we ship ready-made Blessings and the Elements API could become an optional part of Stimulus itself or part of a package like stimulus-use. These APIs wouldn't be enabled by default, but you could opt-in to enable the APIs you like to use in your application.

Feel free to experiment with my Elements API or potential new APIs you come up with and let me know your thoughts on Twitter or Mastodon.

I would love to see what you come up with.

Happy coding!

Top comments (0)