DEV Community

AgentQ
AgentQ

Posted on

Stimulus for Rails Developers Who Hate JavaScript

Welcome back to the "Ruby for AI" series. If Hotwire and Turbo are Rails' answer to "how far can we get with less JavaScript," then Stimulus is the answer to "okay fine, but I still need a little JavaScript."

That is why Stimulus is so good.

It does not try to replace your whole app architecture. It does not ask you to move all your logic client-side. It just gives you a clean way to attach behavior to HTML.

For Rails developers, especially the ones who do not want to become full-time frontend framework maintainers, that is a great deal.

In this post, we will cover what Stimulus is, the core concepts like controllers, targets, actions, and values, and how to use it for small AI-style interactions without turning your Rails app into chaos.

What Stimulus actually is

Stimulus is a lightweight JavaScript framework built for enhancing server-rendered HTML.

That last part matters.

Stimulus is not trying to own rendering. It assumes your HTML already exists, usually because Rails rendered it. Then it attaches behavior to that HTML.

You write a controller, connect it to an element, and define how it reacts to user interaction.

That makes it a perfect match for Hotwire.

  • Rails renders HTML
  • Turbo updates the page
  • Stimulus adds focused behavior where needed

That stack is incredibly productive.

Why Rails developers tend to like Stimulus

Because it respects your time.

You do not need to invent a frontend application architecture just to:

  • auto-resize a textarea
  • copy generated output to clipboard
  • preview a prompt count
  • toggle advanced options
  • debounce a search input

These are tiny interactions. They deserve tiny tools.

That is the real strength of Stimulus.

The basic mental model

Stimulus has four core ideas you need to know:

  • controller: the behavior unit
  • target: an element your controller references
  • action: an event handler in HTML
  • value: structured data passed from HTML into the controller

That sounds abstract, so let us make it concrete.

Your first Stimulus controller

Suppose you want a prompt textarea that shows live character count.

Create a controller:

// app/javascript/controllers/prompt_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "count"]

  updateCount() {
    this.countTarget.textContent = this.inputTarget.value.length
  }
}
Enter fullscreen mode Exit fullscreen mode

Now wire it to HTML:

<div data-controller="prompt">
  <textarea
    data-prompt-target="input"
    data-action="input->prompt#updateCount"
  ></textarea>

  <p>Characters: <span data-prompt-target="count">0</span></p>
</div>
Enter fullscreen mode Exit fullscreen mode

What happens here?

  • data-controller="prompt" attaches the controller
  • data-prompt-target="input" marks the textarea as a target
  • data-action="input->prompt#updateCount" says: on input event, run updateCount
  • data-prompt-target="count" gives us the counter element

That is the whole pattern.

No giant component tree. No global state drama. Just behavior attached where it belongs.

Controllers: one job, small scope

A Stimulus controller should usually do one focused thing.

Good examples:

  • character counter
  • copy button
  • loading state toggle
  • dropdown opener
  • keyboard shortcut handler

Bad examples:

  • the entire frontend logic for your whole app stuffed into one controller

Stimulus works best when controllers stay small and local.

Targets: your named handles into the DOM

Targets are how your controller finds the elements it cares about.

Example:

static targets = ["prompt", "output"]

fillExample() {
  this.promptTarget.value = "Explain Ruby blocks like I'm a junior developer"
  this.outputTarget.textContent = "Example prompt inserted"
}
Enter fullscreen mode Exit fullscreen mode

And in HTML:

<div data-controller="playground">
  <textarea data-playground-target="prompt"></textarea>
  <div data-playground-target="output"></div>
  <button data-action="click->playground#fillExample">Use Example</button>
</div>
Enter fullscreen mode Exit fullscreen mode

Targets make the DOM interactions readable. You are not hunting through random selectors all over the place.

Actions: event wiring without glue code mess

Actions map browser events to controller methods.

Example:

<button data-action="click->prompt#submit">Run Prompt</button>
<input data-action="keyup->search#filter" />
Enter fullscreen mode Exit fullscreen mode

This is one of the nicest parts of Stimulus. The event wiring stays right next to the HTML that uses it.

That makes Rails views easier to read because the behavior is visible where it matters.

Values: pass data in cleanly

Values are how you give configuration to a controller.

Controller:

// app/javascript/controllers/copier_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = { successMessage: String }
  static targets = ["source", "notice"]

  async copy() {
    await navigator.clipboard.writeText(this.sourceTarget.textContent)
    this.noticeTarget.textContent = this.successMessageValue
  }
}
Enter fullscreen mode Exit fullscreen mode

HTML:

<div
  data-controller="copier"
  data-copier-success-message-value="Copied to clipboard"
>
  <pre data-copier-target="source"><%= @prompt_run.output %></pre>
  <p data-copier-target="notice"></p>
  <button data-action="click->copier#copy">Copy</button>
</div>
Enter fullscreen mode Exit fullscreen mode

That is much cleaner than sprinkling magic constants through the JavaScript.

A practical AI-flavored example

Let us build a small enhancement for a prompt form:

  • show character count
  • disable submit if prompt is empty
  • toggle advanced options

Controller:

// app/javascript/controllers/prompt_form_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "count", "submit", "advanced"]

  connect() {
    this.refresh()
  }

  refresh() {
    const length = this.inputTarget.value.trim().length
    this.countTarget.textContent = length
    this.submitTarget.disabled = length === 0
  }

  toggleAdvanced() {
    this.advancedTarget.classList.toggle("hidden")
  }
}
Enter fullscreen mode Exit fullscreen mode

HTML:

<div data-controller="prompt-form">
  <textarea
    data-prompt-form-target="input"
    data-action="input->prompt-form#refresh"
  ></textarea>

  <p>Characters: <span data-prompt-form-target="count">0</span></p>

  <button
    type="button"
    data-action="click->prompt-form#toggleAdvanced"
  >
    Advanced options
  </button>

  <div class="hidden" data-prompt-form-target="advanced">
    <label>Temperature</label>
    <input type="number" step="0.1" value="0.7" />
  </div>

  <button data-prompt-form-target="submit">Run Prompt</button>
</div>
Enter fullscreen mode Exit fullscreen mode

That is the kind of UX improvement Stimulus is perfect for. Small, useful, and easy to maintain.

Where Stimulus shines in AI apps

Stimulus is great for all the "just enough UI" interactions AI apps tend to need:

  • copy output buttons
  • prompt length counters
  • tab switching between results and metadata
  • loading state indicators
  • inline parameter toggles
  • expandable reasoning or debug panels
  • keyboard shortcuts for prompt workflows

These are meaningful UX upgrades, but they do not justify a huge frontend stack on their own.

Common mistakes with Stimulus

1. Writing giant controllers

If one controller is doing ten unrelated things, split it up.

2. Recreating a frontend framework badly

Stimulus is not trying to be React. Do not force it into becoming one.

3. Ignoring server-rendered HTML strengths

If Rails already knows how to render the UI, let Rails render it.

4. Using Stimulus for everything

Use it for behavior, not for your entire mental model of the app.

Stimulus + Turbo is the sweet spot

This is the combo a lot of Rails developers end up loving:

  • Turbo handles navigation and server-driven updates
  • Stimulus adds focused interactivity

That means you can build apps that feel dynamic without giving up Rails simplicity.

For AI builders, that is a big deal. AI products usually change fast. Prompts change. flows change. UI requirements change. The simpler your stack, the faster you can adapt.

Final takeaway

Stimulus is the right kind of JavaScript for many Rails apps.

It gives you enough power to add polish, responsiveness, and interaction without dragging you into frontend architecture overkill.

That makes it a perfect fit for Rails developers who want to ship AI-powered products without turning every feature into a JS framework debate.

Use Stimulus when you need behavior. Use Turbo when you need server-driven updates. Use Rails to keep the rest boring.

That is a very good stack.

Top comments (0)