# Stimulus Controller Conventions in the Maybe Finance Application

> Discover Maybe Finance's Stimulus controller conventions Learn about data-action attributes, target limits, UI logic, and data-value usage for cleaner frontend development.

- Repository: [Maybe/maybe](https://github.com/maybe-finance/maybe)
- Tags: best-practices
- Published: 2026-03-07

---

**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`](https://github.com/maybe-finance/maybe/blob/main/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`](https://github.com/maybe-finance/maybe/blob/main/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`](https://github.com/maybe-finance/maybe/blob/main/app/javascript/controllers/toggle_controller.js) shows the declarative pattern and target usage:

```erb
<!-- 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>

```

```javascript
// 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`](https://github.com/maybe-finance/maybe/blob/main/app/javascript/controllers/currency_controller.js) demonstrates passing Ruby data via `data-*-value` attributes:

```erb
<!-- app/views/transactions/_row.html.erb -->
<div
  data-controller="currency"
  data-currency-code-value="<%= transaction.currency %>"
  data-currency-amount-value="<%= transaction.amount_cents %>">
</div>

```

```javascript
// 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`](https://github.com/maybe-finance/maybe/blob/main/CLAUDE.md)** – Architectural guidelines prohibiting inline JS and DOM manipulation in ERB (lines 21-26, 33-35).
- **[`app/javascript/controllers/toggle_controller.js`](https://github.com/maybe-finance/maybe/blob/main/app/javascript/controllers/toggle_controller.js)** – Reference implementation showing declarative patterns and target usage.
- **[`app/javascript/controllers/currency_controller.js`](https://github.com/maybe-finance/maybe/blob/main/app/javascript/controllers/currency_controller.js)** – Demonstrates `data-*-value` passing and formatting logic.
- **[`app/javascript/controllers/bulk_select_controller.js`](https://github.com/maybe-finance/maybe/blob/main/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](https://github.com/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`](https://github.com/maybe-finance/maybe/blob/main/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`](https://github.com/maybe-finance/maybe/blob/main/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.