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:
srcis the HTML attribute to be added/updated/removelightboxis the controller identifierimageUrlis 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 > 10then the elementclassattribute will be set to"text-red font-bold". - If
this.count < 10then the elementclassattribute 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.

