More than click and submit: use any event with Stimulus
I recently surprised someone by showing you can use any event besides the common click, submit and keydown events. The Stimulus docs list those seven default events and their shorthand equivalents. Enough for most cases. But the data-action descriptor is event->controller#method where you can bind to anything the DOM throws at you: native element events, global document events, even custom dispatched events. All work the same way.
I put together a small demo repo to show a handful of these in action. Lets go over them and maybe you find a way to refactor and simplify one of your Stimulus controllers.
First a few you probably have used before. A <textarea> fires input on every keystroke. The character-counter controller subtracts the current length from maxLength and updates a live counter.
<textarea data-controller="character-counter" data-action="input->character-counter#update" maxlength="200">
Note the difference. input fires on every change. change fires only on blur. For real-time feedback, input is what you want.
A range slider works the same way. The range-display controller grabs event.target.value and writes it into an <output> element. As the user drags, the displayed number updates instantly.
<input type="range" data-action="input->range-display#update">
<output data-range-display-target="value">42</output>
A checkbox fires change when its checked state toggles. The checkbox-reveal controller inspects event.target.checked and toggles a hidden class on a content section.
<input type="checkbox" data-action="change->checkbox-reveal#toggle">
<div data-checkbox-reveal-target="content" class="hidden">…</div>
A <details> element fires toggle every time it opens or closes. The accordion controller uses this to persist state to localStorage.
<details data-controller="accordion" data-action="toggle->accordion#persist">
The controller reads event.target.open to know whether the element was opened or closed and saves a boolean. On connect, it restores the saved state. Native event, no wrapper library.
A code block fires copy when the user selects text and presses Cmd+C. The clipboard controller shows a “Copied!” tooltip for 1.5 seconds, then hides it.
<pre data-controller="clipboard" data-action="copy->clipboard#showTooltip">
<code>$ rails g controller pages show</code>
<span data-clipboard-target="tooltip" class="clipboard-tooltip">Copied!</span>
</pre>
The copy event fires before the clipboard is populated, so there is time to show feedback.
A <dialog> fires two distinct events. close fires when the dialog closes via a form submission or button click. cancel fires when the user dismisses it with the Esc key. The modal controller tracks both to display the correct status.
<dialog data-action="close->modal#close cancel->modal#cancel" data-modal-target="dialog">
When Esc is pressed, cancel fires first. The controller sets a flag so close (which fires right after) can distinguish between “closed via button” and “cancelled via Esc”.
A form fires submit as a default event (similar to the Turbo events for form submissions). The form controller shows the :prevent action option to stop the browser from reloading.
<form data-controller="form" data-action="submit->form#validating:prevent">
Stimulus uses @ to scope the event listener to a different element. The full descriptor is event@target->controller#method. Leave off @target and it listens on the controller’s element. Set it to @document or @window and it listens globally.
<section data-controller="visibility" data-action="visibilitychange@document->visibility#toggle">
The visibility controller toggles document.title depending on whether the tab is hidden or visible. The @document suffix is the only way to make this work since visibilitychange fires on the document, not on the controller’s element.
This is also how you hook into Turbo events:
<turbo-frame id="comments" data-action="turbo:frame-render@document->comments#highlight">
And how you listen to custom dispatched events from anywhere. A Stimulus controller, a Turbo Stream response, a WebSocket handler. Call dispatchEvent and bind with the exact same syntax.
element.dispatchEvent(new CustomEvent("cart:updated", { detail: { count: 5 } }))
<span data-controller="cart" data-action="cart:updated@document->cart#updateBadge"></span>
Custom events are just DOM events with a custom name. Stimulus treats them identically to native ones.
One more thing. You can go further with custom action options. I wrote about them here. They let you gate whether an action fires based on custom logic. For example, you could register an open option so the accordion’s persist action only runs when the <details> element opens, not when it closes. All declarative in the HTML, no conditionals in the controller.
The seven default events are convenient, but every DOM event works the same way in Stimulus. Knowing this allows you to write more advanced controllers, simpler. 😊
Want to read me more?
-
Shift+Click Selection for Bulk Actions with Stimulus
Learn how to implement Shift+Click multi-selection in your Rails app with Stimulus.js. This tutorial shows you how to build an intuitive bulk-action interface for managing lists of content efficiently. -
Attractive.js 1.0.0: interactive HTML without one line of JavaScript
Attractive.js 1.0.0 is here with a new declarative syntax. Here's why I built it, what changed and the components you can ship with zero JavaScript. -
Stimulus Features You (Didn't) Know
Stimulus is advertised as a modest JavaScript framework, but packs still quite a few features. Lets explore the lesser known ones.
Over to you…
What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!
Comments are powered by Chirp Form
{{comment}}