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]")
}
}
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,
]
// ...
}
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))
}
// ...
}
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
}
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() { /* ... */ } }
}
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
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
}
The definitions
variable now holds an array of definitions, which looks like this:
[
["backdrop", "#backdrop"],
["item", ".item"],
["tippy", "[data-tippy]"]
]
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)
}
}
}
}
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
}
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 }
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>
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)