Ruby on Rails Module 5 – Hotwire: Turbo, Stimulus and real-time
Stimulus controllers
Write a Stimulus controller with actions, targets and values, keep it correct as Turbo swaps elements, and choose Stimulus or Turbo alone.
What you will learn
- Write Stimulus controllers with actions, targets, values, classes and outlets
- Explain how connect and disconnect keep behaviour working after Turbo swaps the element
- Decide between a Stimulus controller and a Turbo-only solution for a given feature
Before you start
What you’ll learn · Before you start
What you will learn
- Write Stimulus controllers with actions, targets, values, classes and outlets
- Explain how connect and disconnect keep behaviour working after Turbo swaps the element
- Decide between a Stimulus controller and a Turbo-only solution for a given feature
Before you start
On this page
A Kaksha fee form should show the total for ten seats, 17,700,000 minor units of the institute’s currency, the
moment someone types 10 in the seats field, without asking the server. A
Stimulus controller: A JavaScript class that Stimulus attaches to any element carrying data-controller with the controller's identifier, giving the element behaviour through actions, targets and values. is a small JavaScript class that Stimulus attaches to any
element carrying data-controller; you write one when the behaviour belongs in the browser and needs no server
request, such as a live total, a character counter, a copy button or an aria-expanded toggle.
A new Rails 8.1 app includes Stimulus beside Turbo: Turbo keeps rendering pages on the server, and a controller adds
only what the server cannot. This lesson takes a controller apart, shows what connect() and disconnect() do when
Turbo swaps elements, and gives a rule for choosing Stimulus or Turbo alone.
A controller connects JavaScript to HTML with data attributes
Stimulus does one thing: it finds elements marked with data-controller and attaches a JavaScript object to
each. The value of the attribute names the controller, and Stimulus creates an instance of the matching class
and keeps it alive as long as the element stays in the page. The Stimulus Handbook calls this a bridge from
HTML to JavaScript, the way the class attribute bridges HTML to CSS.
A new Rails 8.1.4 app has Stimulus wired up already. The generated import map: A JSON object in a script tag of type importmap that tells the browser which file each JavaScript module name stands for, so modules can import each other by name without a bundler. More in “Assets: Propshaft, import maps and CSS” in
config/importmap.rb pins the controllers folder with
pin_all_from "app/javascript/controllers", under: "controllers", and
app/javascript/controllers/application.js starts the Stimulus application. The generated
app/javascript/controllers/hello_controller.js shows the shape:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
} The class extends Controller, defines a connect method and uses this.element to reach the DOM element
that carries data-controller="hello". The method sets the element’s text to “Hello World!”, so any page that
includes <div data-controller="hello"></div> shows that greeting as soon as the element appears.
The file name decides the identifier. Underscores in the name become dashes, so
fee_calculator_controller.js answers to data-controller="fee-calculator", and a slash for a subfolder
becomes two dashes, so users/profile_controller.js answers to users--profile. The Stimulus generator
writes the identifier into the comment at the top of each file it creates:
# The Stimulus generator writes the identifier for you: generate one controller at the
# top level and one in a subfolder, then read the comment at the top of the new file.
require_relative "session"
Session.run(<<~SH)
cd ../kaksha
bin/rails generate stimulus character_counter
bin/rails generate stimulus users/profile
grep -n 'Connects to' app/javascript/controllers/character_counter_controller.js
cat app/javascript/controllers/users/profile_controller.js
SH Output
$ cd ../kaksha
$ bin/rails generate stimulus character_counter
create app/javascript/controllers/character_counter_controller.js
$ bin/rails generate stimulus users/profile
create app/javascript/controllers/users/profile_controller.js
$ grep -n 'Connects to' app/javascript/controllers/character_counter_controller.js
3:// Connects to data-controller="character-counter"
$ cat app/javascript/controllers/users/profile_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="users--profile"
export default class extends Controller {
connect() {
}
}
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby generator_session.rb
Checkpoint
Check: How a controller connects to HTML
Actions, targets and values give a controller its parts
A controller needs three more ideas to do real work:
-
Actions connect DOM events to controller methods. An element with
data-action="click->fee-calculator#calculate"calls the controller’scalculatemethod when clicked. The pattern isevent->controller#method, and Stimulus wires the listener for you. -
Targets name elements the controller works with. Declare them at the top of the class with
static targets = ["total"], and Stimulus createsthis.totalTarget(the first match),this.totalTargets(all matches) andthis.hasTotalTarget(a boolean). Mark an element withdata-fee-calculator-target="total", and your method can read or write it withoutquerySelector. Readingthis.totalTargetwhen no element matches throws an error, so testhasTotalTargetfirst for a target that may be missing. -
Values hold typed data on the controller’s element. Declare them with
static values = { rate: Number, currency: String }, and Stimulus readsdata-fee-calculator-rate-value="18"intothis.rateValueas the number 18. Changethis.rateValueand the attribute updates; definerateValueChanged()and Stimulus calls it whenever the value changes.
Together they keep the controller short. The HTML declares which elements matter and what events trigger which actions; the JavaScript does the work.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- User changes the quantity. The user types in the quantity field. The browser fires an input event on that element.
- Stimulus matches the action. The field has data-action="input->fee-calculator#calculate". Stimulus sees the input event and looks for a fee-calculator controller on this element or an ancestor.
- Stimulus calls the method. Stimulus finds the controller and calls its calculate() method, passing the event object as an argument.
- Method reads targets and values. The method reads this.quantityTarget.value to get the quantity, and this.baseValue and this.rateValue to get the fee and the tax rate from data attributes.
- Method computes the total. The method works out base * quantity, adds the tax rounded to a whole minor unit, and formats the sum with Intl.NumberFormat for the locale and currency values.
- Method writes to a target. The method sets this.totalTarget.textContent to the formatted string. The user sees the updated total instantly, with no server round trip.
The fee calculator below shows all three, one file at a time. First the HTML the server sends: four values
carry the fee per seat (1500000 minor units, so 15,000.00 once a locale and a currency format it), the tax rate, a
locale and a currency; two targets mark the quantity field and the total; one action runs the calculation on every
input event. The form submits to the server as it stands, and the amount already in its output element is the
1770000 minor units that the run below prints for one seat.
<!-- The fee form as the server sends it. It submits and shows a total with no JavaScript;
the controller only makes the total follow the field while someone types. -->
<form action="/fees/quote" method="post"
data-controller="fee-calculator"
data-fee-calculator-base-value="1500000"
data-fee-calculator-rate-value="18"
data-fee-calculator-locale-value="en-IN"
data-fee-calculator-currency-value="INR">
<label for="quantity">Seats</label>
<input id="quantity" name="quantity" type="number" min="1" value="1"
data-fee-calculator-target="quantity"
data-action="input->fee-calculator#calculate">
<p>Total: <output data-fee-calculator-target="total">₹17,700.00</output></p>
<button type="submit">Get a quote</button>
</form> Next the arithmetic, run as plain JavaScript so you can see what it prints for one, two and ten seats and for three locales:
// The fee arithmetic of the Stimulus controller, run on its own. Amounts are whole
// minor units (cents, paise), so the sums stay exact; Intl.NumberFormat turns the
// result into text for a locale and a currency, the two String values of the controller.
function calculateTotal(baseMinor, quantity, taxRatePercent) {
const subtotal = baseMinor * quantity;
const tax = Math.round((subtotal * taxRatePercent) / 100);
return subtotal + tax;
}
function formatAmount(minorUnits, locale, currency) {
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(minorUnits / 100);
}
const base = 1500000; // the fee per seat: 15,000.00 in minor units
const taxRate = 18; // per cent
for (const quantity of [1, 2, 10]) {
console.log(`quantity ${quantity}: total ${calculateTotal(base, quantity, taxRate)} minor units`);
}
const total = calculateTotal(base, 10, taxRate);
for (const [locale, currency] of [['en-US', 'USD'], ['de-DE', 'EUR'], ['en-IN', 'INR']]) {
console.log(`${locale} ${currency}: ${formatAmount(total, locale, currency)}`);
} Output
quantity 1: total 1770000 minor units quantity 2: total 3540000 minor units quantity 10: total 17700000 minor units en-US USD: $177,000.00 de-DE EUR: 177.000,00 € en-IN INR: ₹1,77,000.00
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node fee_total.js
Rupee amounts
With the locale en-IN and the currency INR, the same call groups digits in lakhs: the last line above
prints ₹1,77,000.00 for ten seats, and the output element of the form above starts at ₹17,700.00, the same
formatting of 1770000 minor units for one seat. The form hands those two strings to the controller as values,
so one controller serves every institute whatever its currency.
Last the Stimulus controller, which holds the same arithmetic and only displays its result:
// app/javascript/controllers/fee_calculator_controller.js
// Values carry the numbers, targets mark the quantity field and the total, and an
// action on the field (input->fee-calculator#calculate) runs calculate() on every input.
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="fee-calculator"
export default class extends Controller {
static targets = ["quantity", "total"]
static values = {
base: Number, // fee per seat in minor units (cents, paise)
rate: Number, // tax rate in per cent, e.g. 18
locale: { type: String, default: "en-US" },
currency: { type: String, default: "USD" }
}
connect() {
this.calculate() // show a total as soon as the form appears
}
calculate() {
const quantity = parseInt(this.quantityTarget.value, 10) || 0
const subtotal = this.baseValue * quantity
const total = subtotal + Math.round((subtotal * this.rateValue) / 100)
this.totalTarget.textContent = new Intl.NumberFormat(this.localeValue, {
style: "currency",
currency: this.currencyValue
}).format(total / 100)
}
} Values versus targets
Values are data: a price, a maximum, a state name. Targets are elements: a field to read, a container to update. When you want a piece of information, reach for a value; when you want an element, reach for a target.
Checkpoint
Check: Actions, targets and values
Classes and outlets reach beyond the controller’s own element
Two more declarations round out Stimulus:
Classes let the HTML name CSS classes the controller should use. Declare them with
static classes = ["active"], mark the element with data-fee-calculator-active-class="bg-green-100", and
your code reads this.activeClass to get that string. The controller never hard-codes a class name, so the
same JavaScript works with any design system.
Outlets connect one controller to another. Declare them with static outlets = ["chart"] and mark which
element to connect with data-fee-calculator-chart-outlet="#totals-chart". The element that selector finds
must itself carry data-controller="chart", because an outlet’s name is the other controller’s identifier. Now
this.chartOutlet returns the chart controller instance, and you can call its methods directly; it throws when
no such element exists, so check this.hasChartOutlet first when the chart may be absent. Outlets are an
alternative to dispatching custom events between controllers.
Both keep the controller generic: the HTML tells it which classes to toggle and which other controllers to talk to.
| Declaration | Attribute on the controller’s element | What the code reads |
|---|---|---|
static classes = ["active"] |
data-fee-calculator-active-class="bg-green-100" |
this.activeClass, the class name as a string |
static outlets = ["chart"] |
data-fee-calculator-chart-outlet="#totals-chart" |
this.chartOutlet, the chart controller itself |
Checkpoint
Check: Classes and outlets
Turbo swaps the element, so Stimulus runs disconnect, then connect
When Turbo Drive or a Turbo Frame replaces part of the page, Stimulus notices through the browser’s
MutationObserver and reacts in the next microtask. The old element has left the document, so Stimulus calls
disconnect() on its controller. The new element carries data-controller, so Stimulus creates a controller
for it and calls connect(). One swap is one disconnect() followed by one connect(); Stimulus never
rebuilds the page from scratch, and it cleans up nothing that your code set up outside the element. That is why
code that works on first load still works after Turbo navigation.
The lifecycle has three callbacks:
| Callback | When it runs |
|---|---|
initialize() |
Once, when a controller instance is first created, before its first connect() |
connect() |
Each time the element enters the document carrying the controller’s identifier |
disconnect() |
Each time the element leaves the document, or loses the identifier |
Two connect() calls on one instance are always separated by one disconnect(). When a swap brings in a new
element, the controller is a new instance and initialize() runs again. When Stimulus sees the same element
removed and attached again, it reuses the old instance: connect() runs a second time and initialize() does
not.
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The form is on the page. The form carries data-controller. Stimulus made a controller for it and ran initialize() and connect(). The controller added a resize listener to window.
- A link in the frame is clicked. The link points at a new version of the form. Turbo fetches it from the server; nothing on the page has changed yet.
- Turbo swaps the elements. Turbo removes the old form and puts the new form in its place, in one update of the page.
- disconnect() runs first. Stimulus sees the removal through the browser's MutationObserver and calls disconnect() on the old controller.
- Your cleanup runs. disconnect() gives the function it kept to removeEventListener. Stimulus removes nothing that connect() added to window; if disconnect() is empty, the listener stays.
- A new controller is created. The new form carries data-controller, so Stimulus creates a controller for it and runs initialize().
- connect() runs second. Stimulus calls connect() on the new controller, which adds its own listener. Window now holds one resize listener, not two.
Key idea
One Turbo swap is one disconnect on the old controller followed by one connect on the new one. Anything connect sets up outside the element, disconnect must take down.
The most common mistake is forgetting that cleanup. A listener on a global object such as window or document
does not disappear with the element:
A listener that multiplies
A controller adds window.addEventListener("resize", …) in connect() and has no disconnect(). Every
swap leaves the old listener on the window and adds another, so after five visits one resize runs the handler
five times, on elements that are gone. Keep the bound handler, pass the same function to removeEventListener
in disconnect(), or write the listener in the HTML as data-action="resize@window->chart#redraw" and let
Stimulus install and remove it with the controller.
Checkpoint
Check: Lifecycle callbacks under Turbo
Progressive enhancement: the page should work without JavaScript
Stimulus is designed for progressive enhancement: Building a page that works when delivered from the server and then adding JavaScript to improve it, so the baseline experience is available to everyone.: the page works when delivered from the server, and JavaScript makes it better. If JavaScript fails to load, each of these Kaksha features still works the plain way:
| Feature | Without JavaScript | With a Stimulus controller |
|---|---|---|
| A disclosure panel | A <details> element opens and closes by itself |
An expand-all button opens every panel at once |
| Copying a code sample | Select it and copy by hand | A button writes it to the clipboard |
| The fee total | Submit the form; the server shows the total | The total updates as you type |
| A nested form row | Submit; Rails renders the form with one more row | The controller clones a <template> at once |
The practical rule is to send the working version and let the controller change it. A panel that the server
sends with the hidden attribute and that only JavaScript can open is closed for good when the script fails;
a panel sent open, or a <details>, is readable either way.
Accessible widgets need keyboard support and ARIA state. A disclosure button should be a <button> with
aria-expanded toggled between true and false, and the panel gets the hidden attribute when collapsed,
which hides it from sight and from screen readers alike. Text that changes as the user types should sit in a
live region: An element marked with aria-live that screen readers monitor and announce when its content changes, so dynamic updates are heard without the user moving focus. (aria-live="polite") so screen readers announce the new value
without interrupting. The fee form above uses an output element for its total, which many browsers already
treat as a live region.
Checkpoint
Check: Progressive enhancement
Decide where the state lives: Turbo alone or Stimulus
Turbo moves HTML the server renders: navigation, form submission, server-sent updates. Stimulus changes what the browser already holds. Before you write a controller, ask where the new state lives. If the server holds it (a saved record, a validation error, a row another user added), let Turbo carry it with a frame or a stream. If only the browser holds it (whether a panel is open, how many characters have been typed, how a number is formatted), a request per change is wasted latency, and a controller keeps it local.
| Feature | Pick | Why |
|---|---|---|
| Show a validation error after submit | Turbo alone | The server returns the form with status 422 and Turbo swaps it in |
| Show a student’s fee history after a click | Turbo Frame, no Stimulus | The rows exist only on the server, so a request is needed anyway |
| Open and close text already on the page | A native <details> |
The browser toggles it, handles the keyboard and exposes the open state |
| The same panels with an expand-all shortcut | Stimulus | The shortcut and aria-expanded on a custom button are browser state |
| Character counter, live Markdown preview | Stimulus | Every keystroke changes local state |
| Copy text to the clipboard | Stimulus | The Clipboard API exists only in the browser |
| Add a nested form row | Either | A Turbo Stream costs a request per row; a cloned <template> is instant |
A disclosure is therefore Turbo alone when its content has to come from the server, <details> when the text is
already there and a plain toggle is enough, and Stimulus when the open state needs more than <details> gives.
A server round trip for every interaction is acceptable on fast networks but annoying on slow ones, and a
character counter that waited for the server would lag behind typing.
Checkpoint
Check: Stimulus or Turbo alone
Practise: trace an action through a controller
Four short tasks cover the three objectives, then come the questions. The first three use any Rails 8.1 app:
- Run
bin/rails generate stimulus character_counter, read the identifier in the comment on line 3 of the new file, then writedata-controllerwith that identifier on a form. - Give the controller a
fieldand acounttarget, and an actioninput->character-counter#updateon the field. Makeupdate()write the length ofthis.fieldTarget.valueintothis.countTarget. - Log “connected” in
connect()and “disconnected” indisconnect(), then swap the form with a Turbo Frame link and read the two messages in the browser console. Which comes first? - Without a browser, sort three features into “Turbo alone” or “Stimulus” and name where each state lives: a “saved” notice after a form post, a seat counter that follows the quantity field, and a “copy invoice number” button.
The questions below use the fee calculator: its HTML, its controller and the lifecycle when Turbo replaces the form. Pick an answer and its explanation opens straight away:
Practice that adapts to you
Practise this lesson
7 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does a Stimulus controller need to update a total when a quantity field changes?
A controller connected to the form element with data-controller. A value to hold the base fee (a number
read from a data attribute). A target on the quantity input and another on the element that shows the total.
An action on the quantity input, such as data-action="input->fee-calculator#calculate", that calls the
calculate method whenever the field changes. The method reads the quantity from its target, multiplies,
formats with Intl.NumberFormat, and writes the result to the total target.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A Stimulus controller adds a resize listener to the window in connect() to adjust a chart when the viewport changes. After Turbo replaces the page section, the chart is gone but resize events still log messages to the console. What went wrong, and how do you fix it?
-
Mid to senior
A help page has 20 collapsible sections. The product manager wants them to open and close instantly, with ARIA state so screen readers announce the change. Would you use Turbo, Stimulus or CSS alone, and what accessibility requirements does each approach meet or miss?
-
Mid
A student enrolment form lets the user add up to three guardian contacts. Each row has name, phone and relation fields. How would you implement adding and removing rows with Stimulus, and what Rails pattern on the server accepts the nested data?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does Stimulus attach a controller to the page?
It connects the JavaScript class to every element whose data-controller attribute names that controller's identifier.
-
What do actions, targets and values give a Stimulus controller?
Actions wire DOM events to its methods, targets name the elements it works on, and values carry typed data from data attributes. The HTML declares what matters and the JavaScript does the work.
-
What are Stimulus classes and outlets for?
Classes let a controller read CSS class names from its HTML instead of hard-coding them; outlets let it reach other controllers on the page.
-
Why must disconnect() take down what connect() set up outside the element?
Every Turbo swap runs disconnect() on the old controller and connect() on the new one. A listener that connect added to the window or the document, and nothing removed, multiplies with each visit.
-
What does progressive enhancement ask of a page that uses Stimulus?
The page works as the server sends it, and JavaScript only improves it. Interactive widgets also need keyboard support, ARIA state and live regions.
-
When do you reach for Stimulus rather than Turbo alone?
When the state exists only in the browser, such as a total that updates while a quantity is typed. State the server holds travels with Turbo.
Key takeaways
- A Stimulus controller is a JavaScript class that Stimulus attaches to any element carrying
data-controllerwith the controller’s identifier. - Actions, targets and values wire DOM events, named elements and typed data to the controller through data attributes, so HTML declares what matters and JavaScript does the work.
- Classes and outlets let the controller read CSS class names and reach other controllers without hard-coding identifiers.
- A Turbo swap runs
disconnect()on the old controller andconnect()on the new one, so behaviour survives navigation; anythingconnect()sets up outside the element,disconnect()must take down, or the listener multiplies with every visit. - Progressive enhancement means the page works from the server and JavaScript improves it; accessible widgets need keyboard support, ARIA state and live regions.
- Ask where the state lives: Turbo carries state the server holds, and a Stimulus controller keeps state only the browser holds.
Terms in this lesson
- live region
- An element marked with aria-live that screen readers monitor and announce when its content changes, so dynamic updates are heard without the user moving focus.
- progressive enhancement
- Building a page that works when delivered from the server and then adding JavaScript to improve it, so the baseline experience is available to everyone.
- Stimulus controller
- A JavaScript class that Stimulus attaches to any element carrying data-controller with the controller's identifier, giving the element behaviour through actions, targets and values.
References
- Stimulus Handbook: Introduction (Hotwire)
- Stimulus Reference: Controllers (Hotwire)
- Stimulus Reference: Lifecycle Callbacks (Hotwire)
- Stimulus Reference: Actions (Hotwire)
- Stimulus Reference: Targets (Hotwire)
- Stimulus Reference: Values (Hotwire)
- Stimulus Reference: Outlets (Hotwire)
- Stimulus Reference: CSS Classes (Hotwire)
- MDN Web Docs: the details element (Mozilla)
- MDN Web Docs: the output element (Mozilla)
- MDN Web Docs: Intl.NumberFormat (Mozilla)
- Working with JavaScript in Rails (Ruby on Rails)
- MDN Web Docs aria-live (Mozilla)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress