Stimulus from Scratch: JavaScript That Lives in Your Markup
This article is for people who write server-rendered HTML and want to add interactivity to it without standing up a separate client-side application. No prior knowledge of Hotwire is required: we will go from the very first controller all the way to controllers talking to each other.
The examples come from the live code of an online store, but they have been simplified down to the essentials. All of them run in an ordinary browser from a single HTML file - at the end of each section there is something you can try with your own hands.
If you have already read Turbo Frames from Scratch, then Stimulus is the second half of the same story: Turbo is responsible for the HTML arriving over the network, Stimulus is responsible for how elements behave once they are in the browser.
Let's start with the pain
Imagine a product card with an "add to cart" button. The first version of the code almost always looks like this:
<button onclick="addToCart(42)">Add to cart</button>
or slightly more "grown up":
$(document).ready(function () {
$('.add-to-cart').on('click', function () {
// ...
});
});
It works. The problems show up later, and there are four of them.
First: the code has no idea when the element appeared. $(document).ready fires once. If the card was loaded over ajax, rendered after filtering, or returned inside a modal, the handler will never be attached to it. Then comes the manual "re-initialize after loading", and every new piece of dynamic content has to remember about it.
Second: handlers get duplicated. If initialization does end up running twice, the click fires twice and two items land in the cart. This is hard to track down, because the bug depends on the user's navigation history rather than on the code you see on screen.
Third: JS searches the whole page for elements. $('.add-to-cart') is a global lookup. Rename a class in the markup for styling reasons and you break the logic. Render two cards next to each other and the handler has no idea which one the clicked button belongs to, so the journey through closest() and parentNode begins.
Fourth: it is unclear what is attached to an element. You open the markup, see <div class="card">, and have no idea whether it has any behavior. To find out, you have to grep through all the JS.
Stimulus solves exactly these four things. It does not render HTML and does not store application state. It does one job: it connects existing markup to JavaScript classes and makes sure that connection appears and disappears together with the elements.
A short bit of history: how the industry arrived at Stimulus
To understand why Stimulus looks the way it does, it helps to remember what it is pushing off from.
2006-2010: the jQuery era. The original problem was not architecture but the fact that browsers were incompatible with each other. jQuery gave you one API on top of IE6 and Firefox and became the de facto standard for years. The working model was simple: the server renders HTML, the script finds the elements it needs with selectors and does something to them. That is exactly the model we picked apart in the previous section - together with its four problems.
2010-2012: state moves to the client. Interfaces started living longer than a single page, and it turned out that "find an element and change it" scales badly. Backbone.js (2010), Knockout, AngularJS (2010), and Ember (2011) brought models, data binding, and a router into the browser. Logic started moving from the server to the client.
2013-2019: the frontend becomes a separate application. React (2013) proposed the formula "UI is a function of state" plus a virtual DOM, and Vue (2014) made the same idea friendlier. By the time hooks arrived (2019), a typical project looked like this: the server serves a JSON API, and the client is a standalone application with its own build pipeline, router, store and, optionally, server-side rendering with hydration. For complex products this is justified. For an online store where 90% of the pages are ordinary lists and cards, the price of that split turned out to be noticeable: two applications, two data models, two teams.
2018-2021: the pendulum swings back. Almost simultaneously, approaches appear that return HTML to the server while keeping the interface alive: Phoenix LiveView (2018) in the Elixir world, Stimulus (2018) and Turbo/Hotwire (2020) in Rails, Livewire (2019) in Laravel, Alpine.js (2019) as "jQuery with state", and htmx (2020, grown out of intercooler.js) as an extension of HTML with hypermedia attributes. Tellingly, React itself arrived at a similar thought: React Server Components and the Next.js app router are also about "render on the server, ship markup".
In this picture Stimulus is not a revolution but a deliberate rollback to the jQuery model with its four birth defects removed.
Where Stimulus stands today
The project grew inside Basecamp: the repository was opened in December 2016, and the public 1.0 release happened in January 2018.
| Version | Date | What happened |
|---|---|---|
| 1.0 | January 2018 | Public release, extracted from Basecamp's code |
| 1.1 | August 2018 | API stabilization |
| 2.0 | December 2020 | Ships alongside the Hotwire announcement |
| 3.0 | September 2021 | Moves to the @hotwired/stimulus package, drops mandatory Webpacker |
| 3.2 | November 2022 | Outlets appear |
| 3.2.2 | August 2023 | The latest release to date |
Three years without a new release looks alarming until you look at what exactly is not being shipped. The repository is active - documentation fixes, dependency updates - but the API is considered finished: the framework has around ten public concepts and there is nothing left to add. For a library that deliberately does not grow, the absence of major versions is more a sign of completeness than of abandonment. Since Rails 7 (December 2021) Hotwire has been the default option, so every new Rails application arrives with Stimulus out of the box.
There is no point in having illusions about popularity. The numbers as of August 2026:
| Package | Weekly npm downloads | GitHub stars |
|---|---|---|
| React | ~115M | - |
| jQuery | ~14.7M | ~60k |
@hotwired/turbo |
~925k | ~7.4k |
@hotwired/stimulus |
~746k | ~13k |
| Alpine.js | ~464k | ~32k |
| htmx | ~197k | ~49k |
This table has to be read carefully: a significant share of htmx and Alpine usage goes through a CDN rather than npm, so their share is understated, and GitHub stars measure interest rather than adoption. But the order of magnitude is clear: Stimulus is roughly one percent of React's scale and a firmly niche story that almost entirely overlaps with the Rails world.
The practical conclusion is this. Stimulus does not win on popularity and is not trying to: it has no component ecosystem, no market of off-the-shelf solutions, and no crowd of candidates with it on their resume. People pick it not instead of React, but instead of a hand-rolled $(document).ready in a project where the server renders the HTML anyway. If that is your case - read on and we will look at how it works.
The mental model: HTML is the source of truth
In React or Vue, JavaScript is in charge: state lives in the component and the DOM is derived from it. Change the state and the framework re-renders the markup.
In Stimulus it is the other way round. The HTML sent by the server is in charge. JavaScript merely "plugs into" it through data attributes. State is kept in the DOM itself wherever possible: in an input's value, in the presence of a CSS class, in an attribute.
Under the hood this is extremely simple. On startup, Stimulus attaches a MutationObserver to the document - the browser mechanism for watching DOM changes. From then on it reacts to elements with the right attributes appearing and disappearing:
sequenceDiagram
participant S as Server
participant D as DOM
participant M as MutationObserver
participant C as Controller
S->>D: HTML with data-controller="hello"
D->>M: node added
M->>C: create a class instance
C->>C: initialize()
C->>C: connect()
C->>D: attach listeners from data-action
Note over D,C: the element lives and reacts to events
D->>M: node removed
M->>C: disconnect()
C->>D: detach listeners from data-action
From this follows the main property: you do not care where the element came from. Whether it arrived in the initial HTML, came inside a Turbo Frame, or was inserted via innerHTML from some other code - connect() will be called either way, exactly once per element. Problems "one" and "two" from the previous section disappear not because you carefully worked around them, but because they can no longer be reproduced.
Your first controller in two minutes
Save this into an index.html file and open it in a browser. No build step, no Node, no Rails required.
<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body>
<div data-controller="hello">
<input data-hello-target="name" type="text" placeholder="Your name">
<button data-action="click->hello#greet">Say hello</button>
<p data-hello-target="output"></p>
</div>
<script type="module">
import { Application, Controller } from 'https://unpkg.com/@hotwired/stimulus/dist/stimulus.js'
// put it on window so we can poke at it from the browser console
window.Stimulus = Application.start()
Stimulus.register('hello', class extends Controller {
static targets = ['name', 'output']
greet() {
this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!`
}
})
</script>
</body>
</html>
All three basic concepts are already visible here.
data-controller="hello" marks the element the controller is responsible for. That element is available inside the class as this.element, and it also bounds the scope of visibility: the controller sees nothing outside itself.
data-hello-target="name" marks an interesting element inside. In the class it turns into this.nameTarget. Note the attribute format: data-[controller name]-target. This exists so that nested controllers do not steal each other's elements.
data-action="click->hello#greet" reads as "on the click event, call the greet method of the hello controller". You do not need to write addEventListener or any unsubscribe logic - Stimulus attaches the listener on connect and removes it when the element goes away.
The key idea: by looking at the HTML alone, you already know what behavior the block has and which event triggers what. You do not need to go hunting through JS files.
Try it: open the browser console and run Stimulus.debug = true. Stimulus will start logging every element connection and every method call - very handy when something "doesn't fire".
Naming conventions
In a real project you do not register controllers by hand: the bundler picks up every file from the controllers/ folder and derives the identifier from the filename. The rule is simple - underscores and slashes turn into dashes:
| File | Identifier | In markup |
|---|---|---|
hello_controller.js |
hello |
data-controller="hello" |
cart_badge_controller.js |
cart-badge |
data-controller="cart-badge" |
car_picker_plate_controller.js |
car-picker-plate |
data-controller="car-picker-plate" |
Inside the class the names are already camelCase: the countryIso2 target is written in markup as data-car-picker-plate-target="countryIso2" and read in code as this.countryIso2Target.
You can attach several controllers to one element, separated by spaces: data-controller="analytics tooltip". Each gets its own independent instance. This is the main way to reuse code: not inheritance, but composition of small controllers.
The lifecycle: three methods you need to know
A controller has several reserved methods that the framework itself calls:
export default class extends Controller {
initialize() {
// once for the entire lifetime of the instance
}
connect() {
// every time the element enters the DOM
}
disconnect() {
// every time the element leaves the DOM
}
}
The easiest way to see the order of calls is with a live scenario: the user opens a page, follows a link, and comes back. With Turbo this happens without reloading the document, so the whole cycle fits into a single session:
sequenceDiagram
autonumber
actor U as User
participant T as Turbo
participant D as DOM
participant C as Controller instance
U->>D: opened the page
D->>C: initialize()
D->>C: connect()
Note right of C: bring the block into the right state
U->>T: click on a link
T->>D: replaces the page contents
D->>C: disconnect()
Note right of C: remove our listeners,<br/>kill timers and requests
U->>T: back button
T->>D: restores markup from the cache
D->>C: initialize() and connect() for a new instance
Note right of C: class properties are reset,<br/>state has to come from the DOM
The key moment is the last step: Turbo inserts new nodes rather than reviving the old ones, so the controller is created from scratch. Anything you stored in this.some_field does not survive that point.
connect() is the place for everything that has to happen when the block appears on screen. For example, bringing a button into the state that matches the selected list item:
export default class extends Controller {
static targets = ['select', 'button', 'addedIcon', 'notAddedIcon']
connect() {
this.sync()
}
sync() {
const option = this.selectTarget.selectedOptions[0]
const added = option.dataset.added === 'true'
this.buttonTarget.classList.toggle('btn-primary', added)
this.addedIconTarget.classList.toggle('d-none', !added)
this.notAddedIconTarget.classList.toggle('d-none', added)
}
}
There is a detail here that surprises many people: connect() is not called once. If the element was cut out of the DOM and inserted back, there will be a disconnect() and then another connect(). That is why the code in connect() must be idempotent: a repeat call must not break state and must not attach a second listener to the same event.
disconnect() is the cleanup. Everything you set up manually and that outlives the element has to be stopped right here:
connect() {
this.onOutsideClick = this.onOutsideClick.bind(this)
document.addEventListener('click', this.onOutsideClick)
}
disconnect() {
clearTimeout(this.debounceTimer)
this.abortController?.abort()
document.removeEventListener('click', this.onOutsideClick)
}
The rule: if a listener is attached to document or window, if a setInterval is running, if an object from a third-party library was created - you have to clean up after them. Everything declared through data-action is removed by Stimulus itself.
A classic beginner mistake: attaching a listener to document in connect() and forgetting about disconnect(). On an ordinary site this goes unnoticed, but with Turbo a user visits dozens of pages per session without a reload - and by the end they have a hundred dead handlers, each of them holding a long-deleted DOM in memory.
Targets: references to elements instead of selectors
Targets replace querySelector. You declare a list of names and get three things for each:
export default class extends Controller {
static targets = ['child']
toggle() {
this.childTarget // the first one found (throws if there is none)
this.childTargets // an array of all of them
this.hasChildTarget // whether there is at least one
}
}
The plural form is needed more often than you would think. The classic example is an expandable block with several hidden parts:
export default class extends Controller {
static targets = ['child', 'child2']
toggleChild() {
this.childTargets.forEach((child) => child.classList.toggle('d-none'))
this.child2Targets.forEach((child2) => child2.classList.add('d-none'))
}
}
Three important properties of targets:
- The search only happens inside
this.element. Two identical blocks on a page do not interfere with each other: each controller sees its own elements. This solves problem "three" from the beginning of the article. - The list is dynamic. Add a new element with the right attribute to the DOM and it immediately shows up in
this.childTargets, with no cache invalidation required. this.fooTargetthrows if the element is missing. This is deliberate: an explicit error is better than a silentundefined. If the element is optional, checkthis.hasFooTarget.
There is a flip side to the controller boundary: if you move a target out of the controller's subtree somewhere into body (say, to lift a panel above everything else), it stops being a target. Save a reference to the element before the move, or restructure the markup differently.
Values: how to pass server data into JavaScript
JS often needs data that only the server knows: a price, a currency, an identifier, widget settings. The temptation is to render a <script> with a variable. The right way is values.
export default class extends Controller {
static values = {
price: Number,
currency: String,
discount: { type: Number, default: 0 },
item: Object
}
report() {
const total = (this.priceValue - this.discountValue)
console.log(total, this.currencyValue, this.itemValue.name)
}
}
In markup it looks like this (format: data-[controller]-[name]-value):
<div data-controller="product-card"
data-product-card-price-value="19.90"
data-product-card-currency-value="EUR"
data-product-card-item-value='{"id":42,"name":"Filter"}'>
</div>
Here is what happens: Number automatically turns the string into a number, Object and Array parse JSON, and Boolean understands "true"/"false". You do not need to write parseInt and JSON.parse by hand. Every value comes with this.hasFooValue, and an undeclared value returns the neutral default for its type (0, "", false, {}).
In a Rails template this is usually built with a helper, and the output is the same attribute:
%div{data: {controller: 'product-card',
'product-card-price-value': variant.price,
'product-card-currency-value': current_currency}}
The second, more powerful use of values is reacting to changes. You can declare a callback for each value:
static values = { open: Boolean }
openValueChanged() {
this.element.classList.toggle('is-open', this.openValue)
}
Now it is enough to write this.openValue = true anywhere and the markup updates. You get a small one-way data flow: the value lives in a DOM attribute (you can see it in the inspector, and it survives copying the element), and the rendering is described in one place. The callback also runs on the initial connect, so the starting state is applied too.
Try it: add static values = { count: Number } and a countValueChanged() that writes the counter into outputTarget to the first example. Then have greet() do this.countValue++. Note that you never call a render manually - and that the attribute in the inspector changes right in front of you.
Actions: more about that arrow
The full action descriptor syntax looks like this:
data-action="event->controller#method"
The event part can be omitted if it is obvious for the element: for button it is click, for input it is input, for select it is change, for form it is submit. So data-action="hello#greet" on a button is equivalent to writing it with click->.
Multiple actions are separated by spaces, and the order is preserved:
<button data-action="click->cart#add click->analytics#track">Buy</button>
And now the most useful part.
Global events. With @ you can listen on window or document while staying inside your controller:
<nav data-controller="nav" data-action="scroll@window->nav#onScroll">
This is how a "sticky" header is made: the controller lives on the menu element but reacts to the whole page scrolling. And, importantly, when the element leaves the DOM the window listener is removed automatically.
The same trick works with Turbo events:
<div data-controller="search-results"
data-action="turbo:load@window->search-results#onLoad">
Key and option modifiers. Common cases are baked into the syntax:
<input data-action="keydown.esc->search#dismiss
keydown.enter->search#submit
click->menu#open:once">
:prevent (calls preventDefault), :stop (stops propagation), :once, :passive, and :capture are supported. So the familiar
greet(event) {
event.preventDefault()
// ...
}
shrinks down to data-action="click->hello#greet:prevent".
The method receives the event. The first argument is always event, and most of the time what you need is event.currentTarget - the element the action is attached to (as opposed to event.target, which may be a nested icon). Hence a simple trick: put the data right on the button and read it from dataset:
<button data-action="click->picker#choose" data-code="EE">Estonia</button>
choose(event) {
this.inputTarget.value = event.currentTarget.dataset.code
}
How controllers talk to each other
Sooner or later one block needs to tell another something. The classic case: a product card added an item to the cart, and the badge in the header needs to update. These are different places in the DOM and different controllers. Stimulus has two answers.
Outlets: a direct reference to another controller
An outlet is a way to get an instance of another controller by CSS selector:
<div id="cart-badge" data-controller="cart-badge">
<span data-cart-badge-target="count">0</span>
</div>
<div data-controller="product-card"
data-product-card-cart-badge-outlet="#cart-badge">
<button data-action="click->product-card#add">Add to cart</button>
</div>
// cart_badge_controller.js
export default class extends Controller {
static targets = ['count']
set count(value) {
this.countTarget.textContent = value
this.countTarget.classList.add('badge-pop')
setTimeout(() => this.countTarget.classList.remove('badge-pop'), 600)
}
}
// product_card_controller.js
export default class extends Controller {
static outlets = ['cart-badge']
add(event) {
this.cartBadgeOutlet.count = 12 // calls the neighboring controller's setter
}
}
Note: the card does not reach into someone else's DOM and knows nothing about the badge's markup. It calls a public method, and how the number and the animation are rendered is the badge's business. This is ordinary encapsulation, just done through the DOM.
Just like with targets, there is this.hasCartBadgeOutlet and this.cartBadgeOutlets for the plural form, plus the cartBadgeOutletConnected() / cartBadgeOutletDisconnected() callbacks.
Events: when the sender should not know the receiver
An outlet creates a dependency: the card knows there is a cart somewhere. Sometimes that is unnecessary - for example, when an autocomplete reports "the user picked an option" and who listens to that depends on the page. An event is a better fit then:
// inside the autocomplete controller
selectResult(item) {
this.dispatch('selecting', { detail: { id: item.id } })
}
this.dispatch is a wrapper over CustomEvent that automatically prefixes the name with the controller's identifier. In the example above, a variant-search:selecting event fires on the controller's element. You subscribe to it with the same action descriptor:
<div data-controller="variant-search"
data-action="variant-search:selecting->order-form#fill">
fill(event) {
this.idInputTarget.value = event.detail.id
}
The event bubbles, so you can listen on any parent as well. Meanwhile the sender knows nothing about any order-form: it simply announces that something happened. This is the most decoupled way to connect two pieces of an interface.
The rule for choosing is simple: need to call one specific known block - use an outlet; need to announce a fact to whoever cares - use dispatch.
A practical example: a search field with suggestions
Let's build the thing people actually write in Stimulus on a real project: an autocomplete field. It brings together debouncing, cancelling stale requests, and keyboard navigation.
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['input', 'results']
connect() {
this.items = []
this.activeIndex = -1
this.requestId = 0
this.onOutsideClick = this.onOutsideClick.bind(this)
document.addEventListener('click', this.onOutsideClick)
}
disconnect() {
this.requestId++ // every in-flight response becomes irrelevant
this.cancel()
document.removeEventListener('click', this.onOutsideClick)
}
onInput() {
const query = this.inputTarget.value.trim()
const requestId = ++this.requestId
this.cancel()
this.clear()
if (query.length < 4) return
// Wait for a pause in typing so we don't fire a request per keystroke
this.timer = setTimeout(() => this.fetchResults(query, requestId), 100)
}
async fetchResults(query, requestId) {
this.abortController = new AbortController()
const response = await fetch(`/search.json?q=${encodeURIComponent(query)}`,
{ signal: this.abortController.signal })
const items = await response.json()
// The response may have arrived after the next request - then it is no longer needed
if (requestId !== this.requestId) return
this.items = items
this.render()
}
onKeydown(event) {
if (this.items.length === 0) return
if (event.key === 'ArrowDown') {
event.preventDefault()
this.activeIndex = (this.activeIndex + 1) % this.items.length
this.render()
} else if (event.key === 'Enter' && this.activeIndex > -1) {
event.preventDefault()
this.dispatch('selecting', { detail: this.items[this.activeIndex] })
this.clear()
}
}
cancel() {
clearTimeout(this.timer)
this.abortController?.abort()
}
clear() {
this.items = []
this.activeIndex = -1
this.resultsTarget.innerHTML = ''
}
onOutsideClick(event) {
if (!this.element.contains(event.target)) this.clear()
}
render() {
this.resultsTarget.innerHTML = this.items.map((item, i) => `
<button role="option" data-index="${i}"
class="${i === this.activeIndex ? 'active' : ''}">
${item.title}
</button>`).join('')
}
}
<div data-controller="autocomplete">
<input data-autocomplete-target="input"
data-action="input->autocomplete#onInput keydown->autocomplete#onKeydown">
<div data-autocomplete-target="results" role="listbox"></div>
</div>
Three things make this code worth re-reading.
A requestId counter instead of a lock. Network responses do not arrive in the order the requests were sent. If the user typed "fil" and then "filter", the response for "fil" may come back later and overwrite the correct suggestions. Each request is given a number, and at render time we check that it is still the latest one. AbortController additionally tears down the connection, but it alone is not enough: a race is still possible between "aborted" and "JSON already parsed".
sequenceDiagram
autonumber
actor U as User
participant C as Controller
participant S as Server
U->>C: typed "filte"
C->>S: request #1
U->>C: appended "filter"
C->>S: request #2
S-->>C: response #2 (fast)
C->>C: 2 === requestId, render
S-->>C: response #1 (slow)
C->>C: 1 !== requestId, discard
Note over C: without this check the screen<br/>would show suggestions for "filte"
Incrementing requestId in disconnect(). The user left the page while the request was in flight. Without this line the callback would try to write into a removed DOM.
A manual removeEventListener only for document. The listeners on input are declared in markup and Stimulus takes care of them. But a click outside the block has to be caught globally, so you have to remove it yourself.
Try it: replace fetch with a Promise on an artificial delay (new Promise(r => setTimeout(() => r(fake), Math.random() * 2000))) and remove the requestId check. Type quickly and watch the suggestions "jump" back to a stale variant. This is the most vivid way to understand why the counter is needed.
Stimulus and Turbo: why a plain <script> stops working
If a project has Turbo, ordinary inline scripts start behaving strangely. The reason is that Turbo swaps the page contents rather than reloading the document: DOMContentLoaded no longer fires on navigation, and a <script> inside a replaced fragment may not run again.
Controllers do not have this problem by design: any new markup goes through the MutationObserver, and connect() is called.
But there is a subtler effect - the Turbo cache. When leaving a page, Turbo saves a copy of its current DOM and shows it instantly when the user presses "back". The copy is made from the modified state, including whatever the user typed into the fields. So after going back, a person may see their old search query where they expected an empty field.
The cure is subscribing to the render event:
export default class extends Controller {
// Before each render, reset the fields to their default value,
// otherwise the Turbo cache brings back whatever the user typed earlier.
beforeRender(event) {
event.detail.newBody.querySelectorAll('[data-default]').forEach((input) => {
input.value = input.dataset.default
})
}
}
<form data-controller="search"
data-action="turbo:before-render@window->search#beforeRender">
<input name="q" data-default="">
</form>
From the same family: widgets and analytics that must not fire twice. If an element sends a purchase event, the most reliable way to not send it again on a back navigation is to remove the element itself:
onPurchase() {
track(this.eventValue)
// Remove the element so a reload or a back navigation doesn't send the event twice
this.element.remove()
}
Note how much this is in the spirit of Stimulus: the "event already sent" state is stored not in a JS variable but in the presence of an element in the DOM.
The rakes everyone steps on
State in class properties. this.selectedIds = [] lives exactly as long as the element is in the DOM. Turbo replaced the fragment - the controller is recreated and the array is empty. If state has to survive a re-render, its place is in the DOM (an attribute, a value, a hidden field) or on the server.
Working through document.querySelector. It technically works, but you lose the entire point: the controller stops being reusable and breaks as soon as there are two such blocks on the page. Inside your own subtree use targets, outside it use outlets or events.
A controller that is too big. If a class has fifteen methods and is responsible for a modal, a form, and a table all at once, it is nearly impossible to reuse. Split it into several and attach them to one element separated by spaces.
A forgotten prefix in an attribute. data-target="name" instead of data-hello-target="name" is the most common mistake of the first few days. Nothing happens, and there are no errors in the console. Turn on Stimulus.debug = true.
Dashes versus camelCase. A controller identifier in markup is always dashed (cart-badge), while target and value names are always camelCase (countryIso2). Easy to mix up, hard to find.
Heavy work in connect(). It runs for every instance, and there may be fifty cards on a page. Anything that can be done once globally should be done once globally.
When Stimulus is the wrong choice
Stimulus deliberately does nothing: it has no templates, no reactivity, no router, and no store. That is an advantage exactly as long as the server renders the markup.
Signs that you have hit the boundary:
- the controller has accumulated its own templating system and dozens of lines of HTML string building;
- the same state has to be synchronized between several controllers;
- the interface has to work offline or without talking to the server;
- you need complex transition animations between screens, drag-and-drop of complex lists, or an editor.
In those cases a full-blown framework or a specialized library is a better fit - and that is fine: Stimulus coexists happily with islands of React or Vue on the same page.
The opposite sign, that you are on the right track: the controller fits on a screen, state is read from the DOM, and the server remains the only owner of the truth about the data.
What to try next
Small exercises in increasing order of difficulty, all done in the same single HTML file:
- A character counter. A controller on the form, targets on a
textareaand on a hint. Oninput, show how much of the limit is left. Pass the limit through a value with a default. - An expandable block. A button toggles
openValue, andopenValueChanged()adds and removes a class. Make sure the state is visible in the element's attribute in the inspector. - Two independent blocks. Copy the markup from exercise 2 twice onto one page and make sure the blocks do not interfere with each other.
- Communication. Add a controller to the header that counts open blocks, and wire the blocks to it via
dispatch. Then rewrite it using outlets and compare which version reads better. - A lifecycle check. Write
connect()anddisconnect()with logging, then rundocument.querySelector('#block').remove()in the console and insert the element back. Make sure the order of calls is exactly what you expected.
Links
- Official Stimulus documentation - short, readable in one evening
- Reference for targets, values, outlets, and actions
- Turbo Frames from Scratch - the other half of Hotwire
- MutationObserver on MDN - the mechanism everything is built on