Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with Ruby 4.0.7, rails 8.1.4 and Node.js 24.21.0
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

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

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:

The generated hello_controller.js JavaScript · kaksha/app/javascript/controllers/hello_controller.js
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 Ruby · sessions/generator_session.rb
# 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

  1. Question 1 of 2 What does the data-controller attribute tell Stimulus to do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Create an instance of the named controller class and attach it to the element.

    When Stimulus sees data-controller="fee-calculator", it looks up the FeeCalculatorController class, creates an instance and binds it to that element. The instance stays alive as long as the element is in the document.

  2. Question 2 of 2 A file named app/javascript/controllers/user_profile_controller.js produces which identifier in HTML?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: user-profile

    Stimulus maps file names to identifiers: user_profile_controller.js becomes user-profile, and users/list_item_controller.js becomes users--list-item (double dash for the folder separator).

Actions, targets and values give a controller its parts

A controller needs three more ideas to do real work:

  1. Actions connect DOM events to controller methods. An element with data-action="click->fee-calculator#calculate" calls the controller’s calculate method when clicked. The pattern is event->controller#method, and Stimulus wires the listener for you.

  2. Targets name elements the controller works with. Declare them at the top of the class with static targets = ["total"], and Stimulus creates this.totalTarget (the first match), this.totalTargets (all matches) and this.hasTotalTarget (a boolean). Mark an element with data-fee-calculator-target="total", and your method can read or write it without querySelector. Reading this.totalTarget when no element matches throws an error, so test hasTotalTarget first for a target that may be missing.

  3. Values hold typed data on the controller’s element. Declare them with static values = { rate: Number, currency: String }, and Stimulus reads data-fee-calculator-rate-value="18" into this.rateValue as the number 18. Change this.rateValue and the attribute updates; define rateValueChanged() 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.

Interactive · stage by stage Action to target: how a controller handles input Follow an input event through the fee calculator controller. The action calls a method, the method reads a target and a value, and writes to another target.
Action to target: how a controller handles input, step 6 of 6. Showing DOM event (input event), Action (matched), Method (running), Read (computed), Write (updated).DOM eventinput eventActionmatchedMethodrunningReadcomputedWriteupdatedset textContent

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. User changes the quantity. The user types in the quantity field. The browser fires an input event on that element.
  2. 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.
  3. Stimulus calls the method. Stimulus finds the controller and calls its calculate() method, passing the event object as an argument.
  4. 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.
  5. 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.
  6. 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: values, targets and an action in the HTML HTML · fee_form.html
<!-- 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 in three locales (plain JavaScript) JavaScript · fee_total.js
// 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:

A fee calculator controller with values, targets and an action JavaScript · kaksha/app/javascript/controllers/fee_calculator_controller.js
// 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

  1. Question 1 of 2 Which data-action value calls the calculate method on a click event?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: click->fee-calculator#calculate

    Stimulus actions follow event->controller#method. For a button that should call calculate() on click: data-action="click->fee-calculator#calculate".

  2. Question 2 of 2 The fee per seat is 1500000 minor units. Where does that number belong in a Stimulus controller?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A value on the controller element: data-fee-calculator-base-value="1500000"

    Values carry data in typed attributes. Declare static values = { base: Number } and Stimulus parses data-fee-calculator-base-value="1500000" into this.baseValue as the number 1500000.

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

  1. One question A fee form and a totals chart are both on the page, each with its own controller. How does the fee controller call a method on the chart controller?

    Choose one answer.

    Show the answer: One question

    Answer: Declare an outlet and connect it: static outlets = ["chart"] and data-fee-calculator-chart-outlet="#totals-chart"

    Outlets connect controllers across elements. Declare the outlet, add the selector in HTML, and this.chartOutlet returns the chart controller instance so you can call its methods.

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.

Interactive · message by message Turbo swaps a form: disconnect, then connect Step through one swap and watch the old controller disconnect before the new one connects.
Turbo swaps a form: disconnect, then connect, step 7 of 7. Messages so far: Turbo to Old: element removed; Turbo to New: element added.TurboswappedOldcleaned upNewconnectedelement removedelement added

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. Turbo swaps the elements. Turbo removes the old form and puts the new form in its place, in one update of the page.
  4. disconnect() runs first. Stimulus sees the removal through the browser's MutationObserver and calls disconnect() on the old controller.
  5. 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.
  6. A new controller is created. The new form carries data-controller, so Stimulus creates a controller for it and runs initialize().
  7. 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

  1. Question 1 of 2 A form with data-controller="fee-calculator" is inside a Turbo Frame. The user clicks a link that replaces the frame. How many times does the controller's connect() run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Twice: once on page load and once after the frame swap

    When Turbo replaces a frame, the old element is removed and Stimulus runs disconnect() on its controller; the new element is inserted and Stimulus runs connect() on a new controller. Each appearance of the element in the document calls connect(), always after a disconnect() for the one it replaces.

  2. Question 2 of 2 A controller adds a resize listener to the window in connect(). What must happen in disconnect()?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Remove the listener so it does not fire after the element is gone.

    A listener added to window or document stays until you remove it. Store a reference to the bound function in connect() and call removeEventListener in disconnect().

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

  1. One question Progressive enhancement means the fee form should:

    Choose one answer.

    Show the answer: One question

    Answer: Work by submitting to the server when JavaScript is off, and show a live total when JavaScript is on.

    Progressive enhancement builds on a working baseline. The server renders a form that submits normally; Stimulus adds the live total on top. Users without JavaScript still see and submit the form.

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

  1. One question A user types in a message field and you want to show the character count. Which approach fits?

    Choose one answer.

    Show the answer: One question

    Answer: A Stimulus controller that listens for input events and updates a counter target.

    A character counter is local, fast interaction. Stimulus handles it in the browser; Turbo is for server-rendered updates where the latency is acceptable.

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:

  1. Run bin/rails generate stimulus character_counter, read the identifier in the comment on line 3 of the new file, then write data-controller with that identifier on a form.
  2. Give the controller a field and a count target, and an action input->character-counter#update on the field. Make update() write the length of this.fieldTarget.value into this.countTarget.
  3. Log “connected” in connect() and “disconnected” in disconnect(), then swap the form with a Turbo Frame link and read the two messages in the browser console. Which comes first?
  4. 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.

  1. Question 1 of 7 You add data-controller="fee-calculator" to a div, but connect() never runs and the console shows no error. What is the most likely cause?

    Choose one answer.

    Show the answer: Question 1 of 7

    Answer: The controller is not registered: its file is missing from the controllers index or import map.

    Stimulus creates a controller only for identifiers it has registered through the controllers index (import map). If the file is not loaded or the name does not match, nothing happens and no error is raised.

  2. Question 2 of 7 Which part of a Stimulus controller locates an element so the controller can read or update it?

    Choose one answer.

    Show the answer: Question 2 of 7

    Answer: A target

    Targets locate elements. Declare static targets = ["total"] and data-fee-calculator-target="total", then read this.totalTarget in the controller.

  3. Question 3 of 7 A controller toggles a highlight style, but the designer may rename the CSS class later. How should the controller get the class name?

    Choose one answer.

    Show the answer: Question 3 of 7

    Answer: Declare a Stimulus class: static classes = ["active"] with data-...-active-class="is-highlighted".

    Stimulus classes let the HTML carry the CSS class name. The controller reads this.activeClass and toggles it, so renaming the class is an HTML-only change.

  4. Question 4 of 7 A chart library instance is created in connect(). After navigating away and back several times with Turbo, memory keeps growing. What fixes it?

    Choose one answer.

    Show the answer: Question 4 of 7

    Answer: Destroy the library instance in disconnect().

    Any resource created in connect() that holds references (a chart, an observer, a timer) must be released in disconnect(), or each Turbo navigation leaks another instance.

  5. Question 5 of 7 JavaScript is disabled, so the live fee total never updates. With progressive enhancement, what should still happen?

    Choose one answer.

    Show the answer: Question 5 of 7

    Answer: The form submits to the server, which calculates and shows the fee.

    Progressive enhancement means the server handles the form on its own. Stimulus adds the live total when JavaScript runs, but submitting still works without it.

  6. Question 6 of 7 A help page has 20 sections whose text is already in the page. They must open instantly, and pressing E must expand or collapse all of them while each button keeps a correct aria-expanded. Which approach fits best?

    Choose one answer.

    Show the answer: Question 6 of 7

    Answer: A Stimulus controller that toggles each panel, updates aria-expanded and listens for the shortcut.

    Ask where the state lives. Whether a panel is open is browser state, so no server round trip is needed. A plain toggle of text already on the page is the job of a details element; the expand-all shortcut and aria-expanded on a custom button need a Stimulus controller.

  7. Question 7 of 7 Turbo swaps a section that holds a controller's element for a new copy of the same section. In what order does Stimulus call the callbacks?

    Choose one answer.

    Show the answer: Question 7 of 7

    Answer: disconnect() on the old controller, then initialize() and connect() on the new one.

    The swap is a removal followed by an insertion. Stimulus answers the removal with disconnect() on the old controller and the insertion with initialize() and connect() on the new one, so cleanup code in disconnect() runs once per swap.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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-controller with 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 and connect() on the new one, so behaviour survives navigation; anything connect() 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.