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.

  1. View (ERB) declares the controller, targets, and actions using data-controller, data-target, and data-action attributes.
  2. Stimulus controller defines static targets (and optional values) and implements action methods. No side-effects are added in connect.
  3. Ruby side renders HTML with data baked into data-*-value attributes, 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:

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-action attributes; never manually attach event listeners in connect.
  • 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/components serve only that component; global controllers live in app/javascript/controllers/.
  • Data via values – Pass Ruby data through data-*-value attributes; 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →