StimulusX brings modern reactive programming paradigms to Stimulus controllers.
Features include:
β Β Automatic UI updates with reactive DOM bindings
β Β Declarative binding syntax based on Stimulus' action descriptors
β Β Chainable value modifiers
β Β Property watcher callback
β Β Extension API
Who is StimulusX for?
If you are a Stimulus user and are tired of writing repetitive DOM manipulation code then StimulusX's declarative, live-updating controllerβHTML bindings might be just what you need to brighten up your day. StimulusX will make your controllers cleaner & leaner whilst ensuring they are less tightly coupled to a specific markup structure.
However if you are not currently a Stimulus user then I'd definitely recommend looking at something like Alpine, VueJS or Svelte first before considering a Stimulus + StimulusX
combo, as they will likely provide a more elegant fit for your needs.
β Skip examples and jump to the docs β
Below is an example of a simple counter
controller implemented using StimulusX's reactive DOM bindings.
Tip
You can find a runnable version of this example on JSfiddle β
<div data-controller="counter">
<span data-bind-attr="class~counter#displayClasses">
<span data-bind-text="counter#count"></span> of
<span data-bind-text="counter#max"></span>
</span>
<button data-action="counter#increment">β¬οΈ</button>
<button
data-bind-attr="disabled~counter#count:lte(0)"
data-action="counter#decrement"
>β¬οΈ</button>
</div>
// controllers/counter_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
initialize(){
this.count = 0;
this.max = 5;
}
increment(){
this.count++;
}
decrement(){
this.count--;
}
get displayClasses(){
return {
"text-green": this.count <= this.max,
"text-red font-bold": this.count > this.max,
}
}
}
Add the stimulus-x
package to your package.json
:
npm i stimulus-x
yarn add stimulus-x
You can use StimulusX with native browser module
imports by loading from it from Skypack:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<script type="module">
import { Application } from "https://cdn.skypack.dev/@hotwired/stimulus"
import StimulusX from "https://cdn.skypack.dev/stimulus-x"
// ...see docs below for usage info
</script>
</head>
<body>
</body>
</html>
StimulusX hooks into your Stimulus application instance via the StimulusX.init
method.
import { Application, Controller } from "@hotwired/stimulus";
import StimulusX from "stimulus-x";
window.Stimulus = Application.start();
// You must call the `StimulusX.init` method _before_ registering any controllers.
StimulusX.init(Stimulus);
// Register controllers as usual...
Stimulus.register("example", ExampleController);
By default, all registered controllers will automatically have access to StimulusX's reactive features - including attribute bindings (e.g. class names, data-
and aria-
attributes, hidden
etc), text content bindings, HTML bindings and more.
If you don't want to automatically enable reactivity for all of your controllers you can instead choose to opt-in to StimulusX features on a controller-by-controller basis.
To enable individual controller opt-in set the optIn
option to true
when initializing StimulusX:
StimulusX.init(Stimulus, { optIn: true });
To then enable reactive features on a per-controller basis, set the static reactive
variable to true
in the controller class:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static reactive = true; // enable StimulusX reactive features for this controller
// ...
}
HTML attributes, text and HTML content can be tied to the value of controller properties using data-bind-*
attributes in your HTML.
These bindings are reactive which means the DOM is automatically updated when the value of the controller properties change.
Bindings are specified declaratively in your HTML using data-bind-(attr|text|html)
attributes where the value of the attribute is a binding descriptor.
Attribute binding descriptors take the form attribute~identifier#property
where attribute
is the name of the HTML attribute to set, identifier
is the controller identifier and property
is the name of the property to bind to.
<!-- keep the `src` attribute value in sync with the value of the lightbox controller `.imageUrl` property -->
<img data-bind-attr="src~lightbox#imageUrl">
π Read more: Attribute bindings β
Text and HTML binding descriptors take the form identifier#property
where identifier
is the controller identifier and property
is the name of the property to bind to.
<!-- keep `element.textContent` in sync with the value of the article controller `.title` property -->
<h1 data-bind-text="article#title"></h1>
<!-- keep `element.innerHTML` in sync with the value of the article controller `.proseContent` property -->
<div data-bind-html="article#proseContent"></div>
π Read more: text bindings and HTML bindings β
Note
If you are familiar with Stimulus action descriptors then binding descriptors should feel familiar as they have a similar role and syntax.
Binding value modifiers are a convenient way to transform or test property values in-situ before updating the DOM.
<h1 data-bind-text="article#title:upcase"></h1>
<input data-bind-attr="disabled~workflow#status:is('complete')">
π Read more: Binding value modifiers β
Boolean property values can be negated (inverted) by prefixing the identifier#property
part of the binding descriptor with an exclaimation mark:.
<details data-bind-attr="open~!panel#closed"></details>
Note
The !
prefix is really just an more concise alternative syntax for applying the :not
modifier.
By default StimulusX only tracks changes to top level controller properties to figure out when to update the DOM. This is shallow reactivity.
To enable deep reactivity for a controller (i.e. the ability to track changes to properties in nested objects) you can can set the static reactive
property to "deep"
within your controller:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static reactive = "deep"; // enable deep reactivity mode
// ...
}
Alternatively you can enable deep reactivity for all controllers using the trackDeep
option when initializing StimulusX:
StimulusX.init(Stimulus, { trackDeep: true });
Attribute bindings connect HTML attribute values to controller properties, and ensure that the attribute value is automatically updated so as to stay in sync with the value of the controller property at all times.
They are specified using data-bind-attr
attributes with value descriptors that take the general form {attribute}~{identifier}#{property}
.
<div data-controller="lightbox">
<img data-bind-attr="src~lightbox#imageUrl">
</div>
export default class extends Controller {
initialize(){
this.imageUrl = "https://placeholder.com/kittens.jpg";
}
}
In the attribute binding descriptor src~lightbox#imageUrl
above:
src
is the HTML attribute to be added/updated/removelightbox
is the controller identifierimageUrl
is the name of the property that the attribute value should be bound to
So the image src
attribute will initially be set to the default value of the imageUrl
property (i.e. https://placeholder.com/kittens.jpg
). And whenever the imageUrl
property is changed, the image src
attribute value in the DOM will be automatically updated to reflect the new value.
this.imageUrl = "https://kittens.com/daily-kitten.jpg"
// <img src="https://kittens.com/daily-kitten.jpg">
Boolean attributes such as checked
, disabled
, open
etc will be added if the value of the property they are bound to is true
, and removed completely when it is false
.
<div data-controller="example">
<button data-bind-attr="disabled~example#incomplete">submit</button>
</div>
export default class extends Controller {
initialize(){
this.incomplete = true;
}
}
Boolean attribute bindings often pair nicely with comparison modifiers such as :is
:
<div data-controller="form">
<input type="text" data-action="form#checkCompleted">
<button data-bind-attr="disabled~form#status:is('incomplete')">submit</button>
</div>
export default class extends Controller {
initialize(){
this.status = "incomplete";
}
// called when the text input value is changed
checkCompleted({ currentTarget }){
if (currentTarget.value?.length > 0) {
this.status === "complete"; // button will be enabled
}
}
}
class
attribute bindings let you set specific classes on an element based on controller state.
<div data-controller="counter">
<div data-bind-attr="class~counter#validityClasses">
...
</div>
</div>
// controllers/counter_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
initialize(){
this.count = 0;
}
get validityClasses(){
if (this.count > 10) {
return "text-red font-bold";
} else {
return "text-green";
}
}
}
In the example above, the value of the validityClasses
property is a string of classes that depends on whether or not the value of the count
property is greater than 10
:
- If
this.count > 10
then the elementclass
attribute will be set to"text-red font-bold"
. - If
this.count < 10
then the elementclass
attribute will be set to"text-green"
.
The list of classes can be returned as a string or as an array - or as a special class object.
If you prefer, you can use a class object syntax to specify the class names. These are objects where the classes are the keys and booleans are the values.
The example above could be rewritten to use a class object as follows:
export default class extends Controller {
// ...
get validityClasses(){
return {
"text-red font-bold": this.count > 10,
"text-green": this.count <= 10,
}
}
}
The list of class names will be resolved by merging all the class names from keys with a value of true
and ignoring all the rest.
Text content bindings connect the textContent
of an element to a controller property. They are useful when you want to dynamically update text on the page based on controller state.
Text content bindings are specified using data-bind-text
attributes where the value is a binding descriptor in the form {identifier}#{property}
.
<div data-controller="workflow">
Status: <span data-bind-text="workflow#status"></span>
</div>
export default class extends Controller {
static values = {
status: {
type: String,
default: "in progress"
}
}
}
HTML bindings are very similar to text content bindings except they update the element's innerHTML
instead of textContent
.
HTML bindings are specified using data-bind-html
attributes where the value is a binding descriptor in the form {identifier}#{property}
.
<div data-controller="workflow">
<div class="status-icon" data-bind-html="workflow#statusIcon"></div>
</div>
export default class extends Controller {
initialize(){
this.status = "in progress";
}
get statusIcon(){
if (this.status === "complete"){
return `<i data-icon="in-complete"></i>`;
} else {
return `<i data-icon="in-progress"></i>`;
}
}
}
Inline value modifiers are a convenient way to transform or test property values before updating the DOM.
Modifiers are appended to the end of binding descriptors and are separated from the descriptor (or from each other) by a :
colon.
The example below uses the upcase
modifier to transform the title to upper case before displaying it on the page:
<h1 data-bind-text="article#title:upcase"></h1>
Tip
Multiple modifiers can be piped together one after each other, separated by colons, e.g. article#title:upcase:trim
String transform modifiers provide stackable output transformations for string values.
:upcase
- transform text to uppercase:downcase
- transform text to lowercase:strip
- strip leading and trailing whitespace
Converts the string to uppercase.
<h1 data-bind-text="article#title:upcase"></h1>
Converts the string to lowercase.
<h1 data-bind-text="article#title:downcase"></h1>
Strips leading and trailing whitespace from the string value.
<h1 data-bind-text="article#title:downcase"></h1>
Comparison modifiers compare the resolved controller property value against a provided test value.
<input data-bind-attr="disabled~workflow#status:is('complete')">
They are primarily intended for use with boolean attribute bindings to conditionally add or remove attributes based on the result of value comparisons.
Tip
Comparison modifiers play nicely with other chained modifiers - the comparison will be done against the property value after it has been transformed by any other preceeding modifiers:
<input data-bind-attr="disabled~workflow#status:upcase:is('COMPLETE')">`
:is(<value>)
- equality test (read more):isNot(<value>)
- negated equality test (read more):gt(<value>)
- 'greater than' test (read more):gte(<value>)
- 'greater than or equal to' test (read more):lt(<value>)
- 'less than' test (read more):lte(<value>)
- 'less than or equal to' test (read more)
The :is
modifier compares the resolved property value with the <value>
provided as an argument, returning true
if they match and false
if not.
<!-- input is disabled if `workflow#status` === "complete" -->
<input data-bind-attr="disabled~workflow#status:is('complete')">
- String comparison:
:is('single quoted string')
,:is("double quoted string")
- Integer comparison:
:is(123)
- Float comparison:
:is(1.23)
- Boolean comparison:
:is(true)
,:is(false)
The :isNot
modifier works exactly the same as the :is
modifier, but returns true
if the value comparison fails and false
if the values match.
Important
The :is
and :isNot
modifiers only accept simple String
, Number
or Boolean
values. Object
and Array
values are not supported.
The :gt
modifier returns true
if the resolved property value is greater than the numeric <value>
provided as an argument.
<!-- button is disabled if `counter#count` is > 9 -->
<button data-bind-attr="disabled~counter#count:gt(9)">+</button>
The :gte
modifier returns true
if the resolved property value is greater than or equal to the numeric <value>
provided as an argument.
<!-- button is disabled if `counter#count` is >= 10 -->
<button data-bind-attr="disabled~counter#count:gte(10)">+</button>
The :lt
modifier returns true
if the resolved property value is less than the numeric <value>
provided as an argument.
<!-- button is disabled if `counter#count` is < 1 -->
<button data-bind-attr="disabled~counter#count:lt(1)">-</button>
The :lte
modifier returns true
if the resolved property value is less than or equal to the numeric <value>
provided as an argument.
<!-- button is disabled if `counter#count` is <= 0 -->
<button data-bind-attr="disabled~counter#count:lte(0)">-</button>
:not
- negate (invert) a boolean value
Tip
You can add your own custom modifiers if required. See Extending StimulusX for more info.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static watch = ["enabled", "userInput"];
connect(){
this.enabled = false;
this.userInput = "";
}
enabledPropertyChanged(currentValue, previousValue){
if (currentValue) {
console.log("Controller is enabled");
} else {
console.log("Controller has been disabled");
}
}
userInputPropertyChanged(currentValue, previousValue){
console.log(`User input has changed from "${previousValue}" to "${currentValue}"`);
}
// ...
}
π§ More docs coming soon...
You can add your own modifiers using the StimulusX.modifier
method:
StimulusX.modifier("modifierName", (value) => {
// Do something to `value` and return the result of the transformation.
const transformedValue = doSomethingTo(value);
return transformedValue;
});
π§ Documentation coming soon...
Unfortunately it is not possible to use StimulusX with controllers that define private methods or properties (i.e. with names using the #
prefix). See Lea Verou's excellent blog post on the topic for more details.
If you have existing controllers with private methods and want to add new StimulusX-based controllers alongside them then you should enable explicit controller opt-in to prevent errors being thrown at initialization time.
StimulusX uses VueJS's reactivity engine under the hood and was inspired by (and borrows much of its code from) the excellent Alpine.JS library.
StimulusX is available as open source under the terms of the MIT License.