Stimulus is a JavaScript framework developed by a team at Basecamp, and it aims to augment your existing HTML so things work without too much "connecting" code.
Contrary to other frameworks, Stimulus doesn't take over your front-end, so you can add it without too much hassle to your already running app.
Its documentation is very clear and digestible. Included in its handbook is an example of building a clipboard functionality, which I recommend you go through if you are trying Stimulus for the first time.
Right now we are replicating that functionality and adding a couple more things using a development build specified in this Pull Request (PR)
The Values and Classes APIs #202
This pull request introduces two new APIs to Stimulus: Values and Classes. These APIs are designed to improve upon, and ultimately obviate, the current Data Map API. We plan to ship them together in the upcoming Stimulus 2.0 release.
Values
Most uses of the Data Map API in Basecamp fall under the following categories:
- Storing small strings, such as URLs, dates, or color values
- Keeping track of a numeric index into a collection
- Bootstrapping a controller with a JSON object or array
- Conditioning behavior on a per-controller basis
However, the Data Map API only works with string values. That means we must manually convert to and from other types as needed. The Values API handles this type conversion work automatically.
Value properties
The Values API adds support for a static values
object on controllers. The keys of this object are Data Map keys, and the values declare their data type:
export default class extends Controller {
static values = {
url: String,
refreshInterval: Number,
loadOnConnect: Boolean
}
connect() {
if (this.loadOnConnectValue) {
this.load()
}
}
async load() {
const response = await fetch(this.urlValue)
// ...
setTimeout(() => this.load(), this.refreshIntervalValue)
}
}
Supported types and defaults
This pull request implements support for five built-in types:
Type | Serialized attribute value | Default value |
---|---|---|
Array | JSON.stringify(array) |
[] |
Boolean | boolean.toString() |
false |
Number | number.toString() |
0 |
Object | JSON.stringify(object) |
{} |
String | Itself | "" |
Each type has a default value. If a value is declared in a controller but its associated data attribute is missing, the getter property will return its type's default.
Controller properties
Stimulus automatically generates three properties for each entry in the object:
Type | Kind | Property name | Effect |
---|---|---|---|
Boolean, Number, Object, String | Getter | this.[name]Value |
Reads data-[identifier]-[name]-value
|
Array | Getter | this.[name]Values |
Reads data-[identifier]-[name]-values
|
Boolean, Number, Object, String | Setter | this.[name]Value= |
Writes data-[identifier]-[name]-value
|
Array | Setter | this.[name]Values= |
Writes data-[identifier]-[name]-values
|
Boolean, Number, Object, String | Existential | this.has[Name]Value |
Tests for presence of data-[identifier]-[name]-value
|
Array | Existential | this.has[Name]Values |
Tests for presence of data-[identifier]-[name]-values
|
Note that array values are always pluralized, both as properties and as attributes.
Value changed callbacks
In addition to value properties, the Values API introduces value changed callbacks. A value changed callback is a specially named method called by Stimulus whenever a value's data attribute is modified.
To observe changes to a value, define a method named [name]ValueChanged()
. For example, a slideshow controller with a numeric index
property might define an indexValueChanged()
method to display the specified slide:
export default class extends Controller {
static values = { index: Number }
indexValueChanged() {
this.showSlide(this.indexValue)
}
// ...
}
Stimulus invokes each value changed callback once when the controller is initialized, and again any time the value's data attribute changes.
Even if a value's data attribute is missing when the controller is initialized, Stimulus will still invoke its value changed callback. Use the existential property to determine whether the data attribute is present.
Classes
Another common use of the Data Map API is to store CSS class names.
For example, Basecamp's copy-to-clipboard controller applies a CSS class to its element after a successful copy. To avoid inlining a long BEM string in our controller, and to keep things loosely coupled, we declare the class in a data-clipboard-success-class
attribute:
<div data-controller="clipboard"
data-clipboard-success-class="copy-to-clipboard--success">
and access it using this.data.get("successClass")
in the controller:
this.element.classList.add(this.data.get("successClass"))
The Classes API formalizes and refines this pattern.
Class properties
The Classes API adds a static classes
array on controllers. As with targets, Stimulus automatically adds properties for each class listed in the array:
// clipboard_controller.js
export default class extends Controller {
static classes = [ "success", "supported" ]
initialize() {
if (/* ... */) {
this.element.classList.add(this.supportedClass)
}
}
copy() {
// ...
this.element.classList.add(this.successClass)
}
}
Kind | Property name | Effect |
---|---|---|
Getter | this.[name]Class |
Reads the data-[identifier]-[name]-class attribute |
Existential | this.has[Name]Class |
Tests whether the data-[identifier]-[name]-class attribute is present |
Declarations are assumed to be present
When you access a class property in a controller, such as this.supportedClass
, you assert that the corresponding data attribute is present on the controller element. If the declaration is missing, Stimulus throws a descriptive error:
If a class is optional, you must first use the existential property (e.g. this.hasSupportedClass
) to determine whether its declaration is present.
Unifying target attributes
We've made a change to the target attribute syntax to align them with values and classes, and also to make the controller identifier more prominent by moving it into the attribute name.
The original syntax is:
<div data-target="[identifier].[name]">
and the updated syntax is:
<div data-[identifier]-target="[name]">
The original syntax is supported but deprecated
Stimulus 2.0 will support both syntaxes, but using the original syntax will display a deprecation message in the developer console. We intend to remove the original syntax in Stimulus 3.0.
Try it out in your application
Update the Stimulus entry in package.json
to point to the latest development build:
"stimulus": "https://github.com/stimulusjs/dev-builds/archive/b8cc8c4/stimulus.tar.gz"
It includes new APIs that will be released with version 2.0 of the framework, so they are not yet available with the current stable production release.
What are we building?
A one-time password "copy to clipboard" button what wraps the DOM Clipboard API.
You can access the final working version on Glitch:
Starting off
First, we are creating our base HTML where the one-time password will be and the actual button to copy it:
<div>
<label>
One-time password:
<input type="text" value="fbbb5593-1885-4164-afbe-aba1b87ea748" readonly="readonly">
</label>
<button>
Copy to clipboard
</button>
</div>
This doesn't do anything by itself; we need to add our Stimulus controller.
The controller definition
In Stimulus, a controller is a JavaScript object that automatically connects to DOM elements that have certain identifiers.
Let's define our clipboard controller. The main thing it needs to do? Grab the text on the input field and copy it to the clipboard:
(() => {
const application = Stimulus.Application.start();
application.register("clipboard", class extends Stimulus.Controller {
// We'll get to this below
static get targets() {
return ['source']
}
copy() {
// Here goes the copy logic
}
});
})();
Now, this is a valid controller that doesn't do anything because it's not connected to any DOM element yet.
Connecting the controller
Adding a data-controller
attribute to our div
will enable the connection:
<div data-controller="clipboard">
[...]
Remember the static get targets()
from above? That allows us to access DOM elements as properties in the controller.
Since there is already a source
target, we can now access any DOM element with the attribute data-clipboard-target="source"
:
[...]
<input data-clipboard-target="source" type="text" value="fbbb5593-1885-4164-afbe-aba1b87ea748" readonly="readonly">
[...]
Also, we need the button to actually do something. We can link the "Copy to clipboard" button to the copy
action in our controller with another identifier: data-action="clipboard#copy"
. The HTML now looks like this:
<div data-controller="clipboard">
<label>
One-time password:
<input data-clipboard-target="source" type="text" value="fbbb5593-1885-4164-afbe-aba1b87ea748" readonly="readonly">
</label>
<button data-action="clipboard#copy">
Copy to clipboard
</button>
</div>
Our controller is now automatically connected to the DOM, and clicking the copy button will invoke the copy
function; let's proceed to write it.
The copy function
This function is essentially a wrapper of the DOM Clipboard API. The logic goes like this:
[...]
copy() {
this.sourceTarget.select();
document.execCommand('copy');
}
[...]
We take the source
target we defined earlier, our text input that is, select its content, and use the Clipboard API to copy it to our clipboard.
At this point, the functionality is practically done! You can press the button and the one-time password is now available for you on your clipboard.
Moving further
The copy button works now, but we can go further. What if the browser doesn't support the Clipboard API or JavaScript is disabled?
If that's the case, we are going to hide the copy button entirely.
Checking API availability
We can check if the copy
command is available to us by doing this:
document.queryCommandSupported("copy")
One of the best places to check this is when the Stimulus controller connects to the DOM. Stimulus gives us some nice lifecycle callbacks so we can know when this happens.
We can create a connect
function on our controller and it will be invoked whenever this controller connects to the DOM:
[...]
connect() {
if (document.queryCommandSupported("copy"))
// Proceed normally
}
}
[...]
One way to hide/show the copy button depending on the API availability is to initially load the page with the button hidden, and then displaying it if the API is available.
To achieve this we can rely on CSS:
.clipboard-button {
display: none;
}
/* Match all elements with .clipboard-button class inside the element with .clipboard--supported class */
.clipboard--supported .clipboard-button {
display: initial;
}
Our button is now hidden from the beginning, and will only be visible when we add the .clipboard--supported
class to our div
.
To do it, we modify the connect lifecycle callback.
Here is where we can start to see major differences from this latest development version. With the actual production version you would need to specify the CSS class in the controller, effectively doing this:
[...]
connect() {
if (document.queryCommandSupported("copy"))
this.element.classList.add('clipboard--supported');
}
}
[...]
There is a new, better way to achieve it.
Classes API
Now, CSS classes can be actual properties of the controller. To do so, we need to add some identifiers to our HTML and add a new array to our controller:
<div data-controller="clipboard" data-clipboard-supported-class="clipboard--supported" class="clipboard">
[...]
[...]
application.register("clipboard", class extends Stimulus.Controller {
[...]
static classes = ['supported']
connect() {
if (document.queryCommandSupported("copy"))
this.element.classList.add(this.supportedClass);
}
}
[...]
Great! Now we can access our supported class string from our controller with this.supportedClass
. This will help keep things loosely coupled.
The clipboard real-life example from Stimulus' handbook ends here. Now, to show the other newest additions and use the Classes API once more, we're adding the following functionality:
- A new style to the "Copy to clipboard" button once it has been clicked
- A refresh interval for the one-time password. This will generate a new password every 2.5 seconds
- A data attribute to keep track of how many times the password has been generated
Values API
This, along with the Classes API, is one of the new additions to Stimulus. Before this API you would need to add arbitrary values to your controller with the Data Map API, that is, adding data-[identifier]-[variable-name]
to your DOM element, and then parsing that value in your controller.
This created boilerplate such as getters and setters with calls to parseFloat()
, parseInt()
, JSON.stringify()
, etc. This is how it will work with the Values API:
<div data-controller="clipboard" data-clipboard-supporte-class="clipboard--supported" data-clipboard-refresh-interval-value="2500" class="clipboard">
[...]
[...]
application.register("clipboard", class extends Stimulus.Controller {
[...]
static values = {
refreshInterval: Number
}
connect() {
if (document.queryCommandSupported("copy"))
this.element.classList.add(this.supportedClass);
}
// Access refreshInterval value directly
this.refreshIntervalValue; // 2500
}
[...]
Accessing your controller values is now cleaner since you don't need to write your getters and setters, nor do you need to parse from String to the type you need.
Moving forward, let's write the one-time password refresh.
Implementing password generation
We're going to define a new function to create a new random password. I grabbed this random UUID generator snippet from the internet:
([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
Adding it to our Stimulus controller:
connect() {
if (document.queryCommandSupported("copy"))
this.element.classList.add(this.supportedClass);
}
if(this.hasRefreshIntervalValue) {
setInterval(() => this.generateNewPassword(), this.refreshIntervalValue)
}
}
// copy function
generateNewPassword() {
this.sourceTarget.value = ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
}
[...]
We use setInterval
to refresh our password text field each 2500ms since that's the value we defined in the DOM.
Our refresh feature is now working! Some things still missing:
- Add new style when copy button is clicked
- Keep track of how many times a password is generated
Giving all we have learned so far, this is what's need to be done:
- Add a new CSS class to the stylesheet, DOM element, and controller
- Add this new class when the button is clicked, and remove it when the password is refreshed
- Add to a counter when the password refreshes
This is how it will look at the end:
/* CSS */
.clipboard-button {
display: none;
}
.clipboard--supported .clipboard-button {
display: initial;
}
.clipboard--success .clipboard-button {
background-color: palegreen;
}
<!-- HTML -->
<div data-controller="clipboard"
data-clipboard-refresh-interval-value="2500"
data-clipboard-supported-class="clipboard--supported"
data-clipboard-success-class="clipboard--success"
data-clipboard-times-generated-value="1"
>
<label>
One-time password: <input data-clipboard-target="source" type="text" value="fbbb5593-1885-4164-afbe-aba1b87ea748" readonly="readonly">
</label>
<button data-action="clipboard#copy"
class="clipboard-button" >
Copy to Clipboard
</button>
</div>
// JavaScript
(() => {
const application = Stimulus.Application.start()
application.register("clipboard", class extends Stimulus.Controller {
static get targets() {
return ['source']
}
static values = {
refreshInterval: Number,
timesGenerated: Number
}
static classes = ['supported', 'success'];
connect() {
if (document.queryCommandSupported("copy")) {
this.element.classList.add(this.supportedClass);
}
if(this.hasRefreshIntervalValue) {
setInterval(() => this.generateNewPassword(), this.refreshIntervalValue)
}
}
copy() {
this.sourceTarget.select();
document.execCommand('copy');
this.element.classList.add(this.successClass);
}
generateNewPassword() {
this.sourceTarget.value = ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
this.element.classList.remove(this.successClass);
this.timesGeneratedValue++;
}
// NEW! Read about it below
timesGeneratedValueChanged() {
if(this.timesGeneratedValue !== 0 && this.timesGeneratedValue % 3 === 0) {
console.info('You still there?');
}
}
});
})();
Apart from what we've already discussed about the Values API, there is also something new: Value changed callbacks.
These callbacks are called whenever a value changes, and also once when the controller is initialized. They are connected automatically given we follow the naming convention of [valueName]ValueChanged()
.
We use it to log a message each time the password has been refreshed three times, but they can help with state management in a more complex use case.
Wrapping up
I've created multiple Stimulus controllers for my daily job, and I must say that I always end up pleased with the results. Stimulus encourages you to keep related code together and, combined with the additional HTML markup required, ends up making your code much more readable.
If you haven't tried it yet, I highly recommend going for it! It offers a different perspective, one of magic 🧙🏻♂️.
Thanks for reading me 👋🏼.