Stimulus Controller Conventions in the Maybe Finance Application
The Maybe finance application enforces strict Hotwire Stimulus conventions: declarative actions via data-action attributes, a maximum of seven targets per controller, single-responsibility UI logic, and data passed exclusively through data-*-value attributes rather than inline JavaScript.
The maybe-finance/maybe repository implements a Hotwire-first frontend architecture where Stimulus controllers handle all JavaScript interactivity. These Stimulus controllers follow deliberately opinionated conventions defined in .cursor/rules/stimulus_conventions.mdc and CLAUDE.md to keep the codebase declarative, lightweight, and maintainable without heavy client-side frameworks.
Core Principles of Stimulus Controllers in Maybe
The conventions center on five core principles that govern how controllers are structured and used throughout the application.
Declarative Actions Only
Controllers must use declarative actions via data-action attributes in the HTML. According to .cursor/rules/stimulus_conventions.mdc (lines 8-13), developers should never manually attach event listeners inside the connect() method using addEventListener. The view declares what should happen, and the controller implements the method.
Lightweight and Simple
Each controller must remain lightweight with a maximum of seven targets per controller. The conventions in .cursor/rules/stimulus_conventions.mdc (lines 53-56) require private helper methods and a clear public API, ensuring controllers stay focused and easy to test.
Single Responsibility
Stimulus controllers contain only UI behavior; any domain or business logic lives in Ruby models or services. As specified in .cursor/rules/stimulus_conventions.mdc (lines 57-60), controllers handle interactions like toggling visibility or formatting displays, while data calculations and business rules remain server-side.
Component-Scoped Usage
Controllers follow strict scoping rules based on location. According to .cursor/rules/stimulus_conventions.mdc (lines 62-64), controllers living under app/components are used only by that component's view, while global controllers reside in app/javascript/controllers/. This prevents namespace pollution and clarifies ownership.
Data Passing via Values
Never embed inline JavaScript; pass Ruby data to Stimulus through data-*-value attributes. The CLAUDE.md file (lines 33-35) mandates this approach, while also prohibiting DOM manipulation in ERB templates (lines 21-26). All DOM queries flow through Stimulus targets, not document.getElementById.
How Stimulus Controllers Fit into the Architecture
The conventions create a clear separation of concerns between the server-rendered HTML and client-side behavior.
- View (ERB) declares the controller, targets, and actions using
data-controller,data-target, anddata-actionattributes. - Stimulus controller defines static
targets(and optionalvalues) and implements action methods. No side-effects are added inconnect. - Ruby side renders HTML with data baked into
data-*-valueattributes, keeping the JavaScript layer purely reactive.
This separation yields a SPA-like experience without heavy client-side frameworks, maintaining a testable and maintainable codebase.
Practical Implementation Examples
The following examples demonstrate the conventions using actual patterns from the repository.
Basic Toggle Controller
This example from app/javascript/controllers/toggle_controller.js shows the declarative pattern and target usage:
<!-- app/views/accounts/_balance.html.erb -->
<div data-controller="toggle">
<button
data-action="click->toggle#toggle"
data-toggle-target="button"
class="text-primary">
Show balance
</button>
<div data-toggle-target="content" class="hidden">
<%= number_to_currency(@account.balance, unit: @account.currency) %>
</div>
</div>
// app/javascript/controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["button", "content"]; // ≤ 7 targets → lightweight
toggle() {
this.contentTarget.classList.toggle("hidden");
const hidden = this.contentTarget.classList.contains("hidden");
this.buttonTarget.textContent = hidden ? "Show balance" : "Hide balance";
}
}
The view only declares that a click triggers toggle#toggle. The controller contains no manual event listeners and queries DOM exclusively through targets.
Passing Data with Values
This example from app/javascript/controllers/currency_controller.js demonstrates passing Ruby data via data-*-value attributes:
<!-- app/views/transactions/_row.html.erb -->
<div
data-controller="currency"
data-currency-code-value="<%= transaction.currency %>"
data-currency-amount-value="<%= transaction.amount_cents %>">
</div>
// app/javascript/controllers/currency_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = { code: String, amount: Number };
connect() {
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: this.codeValue,
}).format(this.amountValue / 100);
this.element.textContent = formatted;
}
}
All Ruby-side calculations remain server-side; the controller only formats the provided values. This eliminates inline JavaScript and keeps business logic in Ruby models.
Key Files and References
Understanding the conventions requires familiarity with these canonical source files:
.cursor/rules/stimulus_conventions.mdc– The primary rule set defining declarative actions, target limits, and scoping rules (lines 8-13, 53-64).CLAUDE.md– Architectural guidelines prohibiting inline JS and DOM manipulation in ERB (lines 21-26, 33-35).app/javascript/controllers/toggle_controller.js– Reference implementation showing declarative patterns and target usage.app/javascript/controllers/currency_controller.js– Demonstratesdata-*-valuepassing and formatting logic.app/javascript/controllers/bulk_select_controller.js– Production-level example maintaining the seven-target limit and component scoping.
All files are available in the main branch of the maybe-finance/maybe repository.
Summary
The Maybe application enforces strict Stimulus controller conventions to maintain a lightweight, declarative frontend:
- Declarative actions only – Use
data-actionattributes; never manually attach event listeners inconnect. - Maximum seven targets – Keep controllers lightweight with limited targets and private helper methods.
- Single responsibility – Controllers handle UI behavior only; business logic remains in Ruby models.
- Component scoping – Controllers in
app/componentsserve only that component; global controllers live inapp/javascript/controllers/. - Data via values – Pass Ruby data through
data-*-valueattributes; never use inline JavaScript. - No ERB DOM manipulation – Query DOM exclusively through Stimulus targets, never
document.getElementById.
Frequently Asked Questions
How do Maybe's Stimulus conventions differ from standard Stimulus usage?
Maybe's conventions are more restrictive than standard Stimulus. While base Stimulus allows manual event listeners and unlimited targets, Maybe mandates declarative actions via data-action attributes, caps targets at seven per controller, and strictly separates UI logic from business logic. These rules are enforced through the .cursor/rules/stimulus_conventions.mdc file and architectural guidelines in CLAUDE.md.
Why does Maybe prohibit inline JavaScript in ERB templates?
Inline JavaScript violates separation of concerns and creates maintenance overhead. According to CLAUDE.md (lines 33-35), Maybe requires all data passing to occur through data-*-value attributes, which Stimulus automatically parses into typed values. This keeps JavaScript out of Ruby views and ensures controllers receive clean, type-safe data without parsing inline JSON or hidden variables.
What is the seven-target limit and why does it exist?
The seven-target limit, defined in .cursor/rules/stimulus_conventions.mdc (lines 53-56), restricts controllers to referencing no more than seven DOM elements via static targets. This constraint forces developers to break complex UI components into smaller, focused controllers rather than monolithic scripts. The result is better testability, clearer public APIs, and easier maintenance of the Hotwire-first frontend.
How should I structure a new Stimulus controller for a ViewComponent?
Controllers specific to ViewComponents must live within the component directory under app/components and should only be used by that component's view, as specified in .cursor/rules/stimulus_conventions.mdc (lines 62-64). Global controllers shared across views belong in app/javascript/controllers/. This scoping prevents namespace collisions and makes component dependencies explicit, aligning with Maybe's modular architecture.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →