RUBY ON RAILS · 21 MIN READ ·

Rails Stimulus Controllers: Productiepatronen Voorbij de Counter-Tutorial

Rails Stimulus controllers in productie: targets, values, outlets, lifecycle, debouncing, foutafhandeling, testpatronen en anti-patronen uit echte apps.

Rails Stimulus Controllers: Productiepatronen Voorbij de Counter-Tutorial

Een senior engineer bij een Series B fintech stuurde me vorige maand hun Stimulus-directory en vroeg me “eerlijk te zijn of het te redden was”. Ik opende de map en vond 73 controllers. Twaalf heetten een variatie op form-controller.js. Eén controller was 940 regels lang en had de comment // TODO: split this up someday — Brian, 2023. Brian had het bedrijf verlaten. Het team was verteld dat Stimulus “the Rails way” was en ze hadden hun frontend gebouwd zoals je een rommellade bouwt — door dingen te blijven proppen tot hij niet meer dichtging.

Na negentien jaar Rails heb ik het JavaScript-verhaal vier keer zien verschuiven, en Rails Stimulus controllers zijn de eerste verstandige standaard sinds de Prototype.js-dagen. De library is bewust klein. Hij doet bewust bijna niets. De patronen die in productie werken staan niet in de README — ze komen uit het kijken naar teams die Stimulus naar echte gebruikers shippen en zien waar de code op maand dertien fout gaat. Deze post is het draaiboek dat ik klanten geef: hoe je Rails Stimulus controllers structureert, de patronen die schalen voorbij de counter-tutorial, en de anti-patronen die middagen van je team opeten.

Wat Rails Stimulus Daadwerkelijk Oplost

Stimulus is een JavaScript-framework dat één ding doet: het verbindt DOM-elementen met JavaScript-klassen wanneer ze verschijnen, en ontkoppelt ze wanneer ze verdwijnen. Dat is de hele library. Al het andere — targets, values, actions, outlets — is suiker bovenop dat kernidee.

Het mentale model dat bij de meeste teams klikt: de server geeft HTML terug; Stimulus controllers hangen gedrag aan specifieke elementen in die HTML. Jij bezit de pagina niet. Jij bezit kleine eilandjes interactiviteit binnen een server-gerenderd document. Wanneer Turbo een frame vervangt, verbinden je controllers zich met de nieuwe HTML en ontkoppelen ze van de oude. Er is geen virtuele DOM. Er is geen client-router. Er is geen state-container. De HTML is de state.

Daarom passen Rails Stimulus controllers zo goed bij Turbo. Turbo regelt navigatie en gedeeltelijke updates door HTML te verwisselen; Stimulus regelt de kleine stukjes gedrag — een dropdown, een gedebounced zoekveld, een toggle, een copy-to-clipboard knop — die de verwisselde HTML nodig heeft. Als je merkt dat je Turbo’s werk in Stimulus aan het repliceren bent (routes beheren, client-side state bezitten, formulierdata vasthouden over navigaties), stop dan. Je gebruikt het verkeerde gereedschap.

Kom je uit de React-wereld, dan is de dichtstbijzijnde analogie “veel kleine ongecontroleerde componenten”. Kom je uit jQuery, dan is de analogie “delegated event handlers, maar dan onderhoudbaar”.

De Anatomie van een Productie-Stimulus-Controller

De meeste teams schrijven hun eerste Rails Stimulus controllers door de counter-tutorial te kopiëren. Prima om te leren. Niet prima om te shippen. Een productiecontroller heeft zes zorgen die de tutorial weglaat: lifecycle, targets, values, classes, outlets en cleanup. Hier een volledig voorbeeld, een controller die een zoekveld aanstuurt dat debounced, de server raakt en een resultatenframe vervangt:

// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
import { debounce } from "lodash-es"

export default class extends Controller {
  static targets = ["input", "spinner"]
  static values  = { url: String, delay: { type: Number, default: 250 } }
  static classes = ["loading"]
  static outlets = ["results"]

  initialize() {
    this.search = debounce(this.search, this.delayValue).bind(this)
  }

  connect() {
    this.controller = new AbortController()
  }

  disconnect() {
    this.controller.abort()
  }

  async search() {
    this.element.classList.add(this.loadingClass)
    this.spinnerTarget.hidden = false

    try {
      const response = await fetch(this.urlFor(), {
        headers: { Accept: "text/vnd.turbo-stream.html" },
        signal: this.controller.signal,
      })
      if (!response.ok) throw new Error(response.statusText)
      Turbo.renderStreamMessage(await response.text())
    } catch (error) {
      if (error.name !== "AbortError") this.resultsOutlet.showError(error)
    } finally {
      this.element.classList.remove(this.loadingClass)
      this.spinnerTarget.hidden = true
    }
  }

  urlFor() {
    const url = new URL(this.urlValue, window.location.origin)
    url.searchParams.set("q", this.inputTarget.value)
    return url
  }
}

De bijbehorende HTML, gegenereerd door je Rails-view:

<div data-controller="search"
     data-search-url-value="<%= search_path %>"
     data-search-delay-value="350"
     data-search-loading-class="opacity-60"
     data-search-results-outlet="#results">
  <input type="text"
         data-search-target="input"
         data-action="input->search#search">
  <div data-search-target="spinner" hidden>Bezig met zoeken...</div>
</div>
<div id="results" data-controller="results"></div>

Vijf dingen om op te letten. Eén: static values is geen optionele opsmuk — values zijn hoe je configuratie van server naar client doorgeeft zonder zelf data-attributen te parsen. Twee: static classes houdt Tailwind-klassenamen in de HTML waar designers ze kunnen vinden, niet begraven in JS. Drie: outlets laten de ene controller met de andere praten via een CSS-selector in plaats van door de DOM te graaien. Vier: connect/disconnect zijn een paar en draaien iedere keer dat het element de pagina binnenkomt of verlaat — als je een resource alloceert in connect, geef je die vrij in disconnect. Vijf: de abort controller cancelt requests die nog onderweg zijn wanneer het element verwijderd wordt; de veelvoorkomende bug “ik navigeer weg, het oude request resolveert en stampt de nieuwe pagina in” gebeurt nooit.

Rails Stimulus Controllers Benoemen

Naamgeving is waar de meeste Rails Stimulus controllers codebases gaan rotten. De Stimulus-conventie is één controller per bestand, vernoemd naar wat de controller dóét met het element waaraan hij vastzit. Niet wat het element is. Niet bij welke feature het hoort. Wat de controller doet.

Een goede naam beschrijft gedrag: clipboard, dropdown, auto-submit, password-toggle, slug-from-title, infinite-scroll. Een slechte naam beschrijft een feature of een pagina: checkout, dashboard, user-form, admin-panel. Het eerste type composeert — je hangt dezelfde clipboard controller aan vijf elementen op vijf pagina’s. Het tweede type wildgroeit — elke pagina krijgt zijn eigen reusachtige controller, en wijzigingen aan één pagina raken één gigantisch bestand dat acht ongerelateerde dingen doet.

De regel die ik klanten geef: als je een controller niet kunt hergebruiken op minstens één ander element ergens in de app, doet hij waarschijnlijk te veel. Splits hem op. Het bestand van 940 regels dat Brian achterliet wil bijna altijd twaalf kleine controllers zijn, gekoppeld aan hetzelfde element met data-controller="form auto-submit validation autosave". Stimulus vindt het prima om zoveel controllers als je wil aan één element te koppelen. Maak daar gebruik van.

Targets, Values, Classes, Outlets — Wanneer Welke

Stimulus geeft je vier manieren om een controller met zijn DOM te verbinden. Elke optie lost een ander probleem op, en de verkeerde kiezen is de meest voorkomende code smell die ik tegenkom.

Targets zijn voor elementen die je controller moet lezen of schrijven. Het invoerveld, de spinner, de error-container. Als je merkt dat je in de controller queryt (this.element.querySelector(".foo")), promoveer dat element dan tot een target.

Values zijn voor state en configuratie die van de server komt. URL’s, ID’s, feature flags, default delays. Ze zijn typed, reactief (declareer een valueChanged callback en Stimulus roept hem aan), en overleven Turbo morphs. Values zijn hoe je data van Rails naar de controller stuurt zonder met de hand JSON in data--attributen te parsen.

Classes zijn voor CSS-klassenamen die je wil toevoegen of weghalen. loading-class="opacity-60" in HTML zetten houdt Tailwind-klassen waar de designer ze verwacht. "opacity-60" hardcoden in JS garandeert dat de dag dat iemand de loading-state herontwerpt, ze in Tailwind-config gaan zoeken en zich afvragen waarom er niets verandert.

Outlets zijn voor het praten met andere controllers. Een search controller die een methode op de results controller wil aanroepen, declareert die als outlet. De koppeling is via een CSS-selector, de API is symmetrisch, en je vermijdt de verleiding om strings van events te dispatchen die je door de hele codebase moet greppen. Gebruik outlets wanneer één controller een andere orkestreert. Gebruik events wanneer één controller broadcast naar wie dan ook luistert.

Lifecycle Hooks die je Redden

Elke Rails Stimulus controller heeft een lifecycle, en het team dat hem leert kent shipt minder bugs:

  • initialize draait één keer wanneer de controller geconstrueerd wordt. Gebruik hem om methoden te binden of dingen op te zetten die disconnects overleven (bijvoorbeeld een debounce-functie).
  • connect draait elke keer dat het element in de DOM verschijnt. Alloceer resources, koppel externe listeners, start animaties.
  • disconnect draait elke keer dat het element de DOM verlaat. Geef resources vrij, abort fetches, clear timers. De grootste bron van “memory leak na tien minuten rondklikken” is hier vergeten op te ruimen.
  • *Changed callbacks (urlValueChanged, loadingClassChanged) vuren wanneer een value verandert, ook de eerste keer dat hij gezet wordt.
  • *TargetConnected en *TargetDisconnected vuren wanneer een specifiek target verschijnt of verdwijnt. Gebruik die in plaats van te pollen.

De valkuil is connect en initialize als hetzelfde behandelen. Dat zijn ze niet. Met Turbo kan dezelfde controller-instance één keer aangemaakt worden, maar connect en disconnect vele keren draaien terwijl de gebruiker navigeert. Alles wat moet resetten tussen navigaties komt in connect. Alles wat moet blijven bestaan (een gedebouncede functie, een instance-ID) komt in initialize.

Debouncen, Throttlen en Cancellen

Echte apps vuren acties sneller af dan het netwerk kan antwoorden. De drie patronen die in elke Rails Stimulus controllers codebase voorkomen die ik review:

// 1. Debounce: vuur na het stoppen met typen
import { debounce } from "lodash-es"

initialize() {
  this.search = debounce(this.search, 250).bind(this)
}

// 2. Throttle: vuur hoogstens elke N ms (goed voor scroll, resize)
import { throttle } from "lodash-es"

initialize() {
  this.update = throttle(this.update, 100).bind(this)
}

// 3. AbortController: cancel lopende fetches bij disconnect
connect() {
  this.controller = new AbortController()
}

disconnect() {
  this.controller.abort()
}

async load() {
  const r = await fetch(this.urlValue, { signal: this.controller.signal })
  // ...
}

Dit zijn geen optimalisaties. Dit is correctheid. Zonder debouncen raakt elke toetsaanslag je server. Zonder AbortController leveren race conditions verouderde resultaten af bij de gebruiker nadat die al verder is. De post over Rails webhook processing met idempotentie maakt hetzelfde punt over server-side request handling — op de grens van een netwerk neem je aan dat requests door elkaar lopen, te laat aankomen en overlappen, en schrijf je code die alle drie overleeft.

Met de Server Praten: Turbo Streams Boven JSON

De meeste Rails Stimulus controllers die ik data zie ophalen, doen het verkeerd. Ze vragen JSON, bouwen daarna HTML in JavaScript, en muteren de DOM met de hand. Dat is jQuery in 2026 met een Stimulus-shirt aan.

Het Rails-vormige antwoord is een Turbo Stream ophalen en Turbo hem laten toepassen:

async submit(event) {
  event.preventDefault()
  const response = await fetch(this.formTarget.action, {
    method: "POST",
    body: new FormData(this.formTarget),
    headers: { Accept: "text/vnd.turbo-stream.html" },
  })
  Turbo.renderStreamMessage(await response.text())
}
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comment_form", partial: "form", locals: { comment: Comment.new } %>

De server bezit nog steeds de HTML. Stimulus bezit de trigger. Je krijgt de rendering-snelheid van een SPA zonder een SPA te bezitten. Bouw je nog steeds HTML in JS, vraag dan waarom. Er zijn goede antwoorden — een grafiek, een third-party widget, een gevirtualiseerde lijst — en duizend slechte.

Rails Stimulus Controllers Testen

De grootste reden waarom teams onder-investeren in Stimulus is dat ze denken dat het niet te testen is. Niet waar. Er zijn twee lagen waard om te testen, en beide zijn goedkoop.

Unit-tests met Jest of Vitest. Stimulus controllers zijn gewone ES6-klassen. Je instantieert ze, hangt ze met Application.start() aan een DOM-fragment en assert op de DOM:

// search_controller.test.js
import { Application } from "@hotwired/stimulus"
import SearchController from "../search_controller"
import { fireEvent } from "@testing-library/dom"

test("debounces search", async () => {
  document.body.innerHTML = `
    <div data-controller="search" data-search-url-value="/q">
      <input data-search-target="input" data-action="input->search#search">
    </div>`
  const app = Application.start()
  app.register("search", SearchController)

  global.fetch = jest.fn(() =>
    Promise.resolve({ ok: true, text: () => "" }))

  const input = document.querySelector("input")
  fireEvent.input(input, { target: { value: "rails" } })
  fireEvent.input(input, { target: { value: "rails 8" } })

  await new Promise(r => setTimeout(r, 400))
  expect(global.fetch).toHaveBeenCalledTimes(1)
})

Systeemtests met Capybara en een echte browser. Die dekken de integratie met Rails — de controller mount, values komen goed door, de server antwoordt, de DOM update. Ik schreef over de afwegingen in de senior Rails engineer interview-rubric — elke senior die ik aanneem kan uitleggen waarom beide lagen ertoe doen.

De combinatie is wat echte bugs vangt: unit-tests pinnen de logica van de controller vast, systeemtests bewijzen dat de bedrading klopt. Sla er één over en je shipt op vertrouwen.

Anti-Patronen in Rails Stimulus Controllers

Let hierop. Ik zie ze bij elke audit:

  • De mega-controller. Eén bestand, één controller, tien ongerelateerde verantwoordelijkheden. Als je meer dan drie externe libraries in één controller import, zijn het waarschijnlijk vier controllers die samen een trenchcoat dragen.
  • State leeft in JavaScript. Een controller die de waarheid van “is dit menu open?” in een instance-variabele bewaart in plaats van in een CSS-klasse of data-attribuut, raakt uit de pas zodra Turbo de pagina morpht. Zet de state op het element, niet in de klasse.
  • JSON ophalen om HTML te bouwen. Stimulus is een HTML-first framework. Als je in JS aan het templaten bent, heb je het verkeerde gebouwd.
  • disconnect negeren. Listeners die nooit verwijderd worden, intervals die nooit gecleared worden, fetches die nooit geabort worden. De snelste manier om die te vinden: tien minuten rondklikken in de app en het geheugen in DevTools bekijken.
  • Controllers koppelen via globale events. document.dispatchEvent(new CustomEvent("user:updated")) vanuit één controller en luisteren in vijf andere is prima voor twee. Bij twintig is het ondoorzoekbare spaghetti. Outlets zijn het betere antwoord voor één-op-één of één-op-bekende-velen. Reserveer events voor één-op-anonieme-velen.
  • Naamgeving naar pagina’s, niet naar gedrag. checkout-controller.js wordt over acht maanden checkout-v2-controller.js. address-autocomplete-controller.js is over vijf jaar nog steeds nuttig.
  • In de DOM van andere controllers grijpen. Als controller A elementen bevraagt die door controller B beheerd worden, heb je niet twee controllers — je hebt één controller in twee bestanden. Voeg ze samen of gebruik een outlet.

Wanneer NIET Rails Stimulus Gebruiken

Stimulus is het juiste antwoord voor bijna elk interactief eilandje in een Rails-app. Het is het verkeerde antwoord voor drie dingen:

  1. Zware client-side state. Een complexe spreadsheet, een real-time canvas, een drag-and-drop kanban met optimistische updates en undo — die willen een echt reactief framework. Embed React of Svelte voor het eilandje; laat Stimulus de rest van de pagina doen.
  2. Turbo vervangen. Gebruik je Stimulus om HTML op te halen, een sectie te wisselen en de URL bij te werken, dan herinventeer je Turbo. Gebruik Turbo.
  3. Iets wat een CSS-only oplossing had kunnen zijn. Een details/summary disclosure heeft geen controller nodig. Een modal die niet meer nodig heeft dan open/dicht kan een <dialog> zijn met wat klassentoggles. Stimulus is goedkoop, maar gratis is goedkoper.

Migratie: Van jQuery of Inline JS naar Stimulus

De helft van de Rails-apps die ik mag bekijken heeft ergens een application.js met $(document).ready(function() { ... }) en 600 regels naamloze jQuery. Migreren naar Rails Stimulus controllers is mechanisch, niet magisch. Het patroon dat werkt:

  1. Vind een afgesloten blok jQuery (bv. “de autocomplete op het zoekveld”).
  2. Maak een controller met hetzelfde gedrag. Gebruik targets voor de elementen die hij raakt.
  3. Vervang de jQuery-selector door een data-controller-attribuut op het parent-element.
  4. Verwijder het jQuery-blok. Verifieer dat het nog werkt.
  5. Volgende. Migreer niet alles tegelijk.

Je kunt de oude code en Stimulus oneindig lang naast elkaar laten draaien. Behandel het zoals ik beschreef in de post over het overnemen van een legacy Rails-codebase — het doel is geen heroïsche herschrijving, maar een gestage stroom kleine winsten waardoor de app op vrijdag beter is dan op maandag.

Productiecijfers

Drie projecten die ik dit jaar migreerde, voor en na overstap naar Rails Stimulus controllers:

  • Een B2B-dashboard: 14.000 regels jQuery en inline <script>-tags geslonken tot 38 Stimulus controllers, in totaal 1.900 regels. Time-to-interactive op het dashboard daalde van 1,4s naar 480ms. De snelheid van het team op UI-wijzigingen verdubbelde ruwweg — omdat de nieuwe code doorzoekbaar en testbaar was.
  • Een marketplace SaaS: 9 React-microapps geconsolideerd tot Turbo + 22 Stimulus controllers. JavaScript-bundle daalde van 410KB naar 71KB. De server-rendered HTML was er al; de React was decoratief.
  • Een fintech onboardingflow: 47 grotendeels ongeteste inline event handlers vervangen door 14 Stimulus controllers en 96 unit-tests. Bugs gemeld door QA in het volgende kwartaal daalden 64%. De winst kwam uit de testsuite, niet uit Stimulus zelf — maar Stimulus is wat de tests goedkoop maakte om te schrijven.

Het patroon is consistent: minder code, snellere pagina’s, minder bugs en een frontend die nieuwe medewerkers op dag één kunnen lezen.

Veelgestelde Vragen

Zijn Rails Stimulus controllers een vervanging voor React in Rails-apps?

Voor de meeste Rails-apps wel. Stimulus plus Turbo geeft je server-rendered HTML, gedeeltelijke updates en kleine interactieve eilandjes zonder dat je een client-side framework hoeft te bezitten. Voor apps met zware lokale state — collaboratieve editors, complexe formulieren met cross-field validatie, live dashboards — embed je React of Svelte voor die specifieke eilandjes en laat je Stimulus al het andere doen. Het is een keuze per feature, niet per app.

Wat is het verschil tussen Stimulus targets, values en outlets?

Targets zijn DOM-elementen binnen het element van de controller die de controller leest of schrijft (this.inputTarget). Values zijn typed configuratie die van de server komt (this.urlValue). Outlets zijn referenties naar andere controllers op de pagina, gekoppeld via een CSS-selector (this.resultsOutlet.showError(error)). Eenvoudige regel: targets zijn zelfstandige naamwoorden binnen je scope, values zijn configuratie, outlets zijn andere actoren.

Hoe test ik Rails Stimulus controllers?

In twee lagen. Unit-test elke controller als een gewone ES6-klasse met Jest of Vitest — instantieer hem, koppel hem aan een DOM-fragment, assert op het resultaat. Systeemtest de integratie met Capybara en een echte browser om te bewijzen dat de controller mount, values doorkomen en Rails correct antwoordt. Beide lagen zijn goedkoop zodra je ze hebt opgezet. Eén overslaan laat een gat dat in productie bijt.

Moet ik Stimulus gebruiken of gewoon inline JavaScript schrijven?

Stimulus, bijna altijd. Inline scripts overleven Turbo-navigaties niet netjes, zijn lastig te testen en zijn onzichtbaar voor grep. Rails Stimulus controllers geven je gratis lifecycle hooks, gestructureerde DOM-toegang en een naamgevingsconventie. Inline JS is alleen prima voor eenmalige page-initialisatie die geen enkele navigatie overleeft — en zelfs dan is een controller zelden slechter.


Hulp nodig bij het ontwarren van een Rails-frontend of bij de overstap van jQuery naar Stimulus en Turbo? TTB Software is gespecialiseerd in Rails-modernisering en frontend-architectuur voor productteams. We doen dit al negentien jaar.

#rails-stimulus #stimulus-controllers #stimulus-js #rails-hotwire #rails-frontend #ruby-on-rails #rails-8

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software