mcpField
S
stimulus-coder

stimulus-coder

github.com/majesticlabs-devDevelopment50179 installsUpdated 7w ago
Repository

About stimulus-coder

stimulus-coder is a skill for developers building interactive interfaces with Stimulus.js and Hotwire. It offers patterns for maintainable controllers, emphasizing HTML-based state and behavior organized through Targets, Values, Actions, and lifecycle management.

Key features

  1. 01Reference interface elements with Targets
  2. 02Manage state in HTML using Values
  3. 03Declare event handling with Actions
  4. 04Coordinate controllers with events and Outlets
  5. 05Pair connect and disconnect cleanup

Use cases

  1. 01Implement reusable Stimulus controllers
  2. 02Configure controllers through HTML attributes
  3. 03Coordinate interactions among controllers
  4. 04Clean up resources during Turbo navigation

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/majesticlabs-dev/majestic-marketplace --skill stimulus-coder

After installing, type/stimulus-coderin Claude Code to confirm it loaded.

Bundled filesreferences/architecture-patterns.mdreferences/lifecycle-and-events.md

Details

From the project README

Stimulus Coder

Audience: Developers building interactive UIs with Stimulus.js and Hotwire.

Goal: Write maintainable Stimulus controllers where state lives in HTML and controllers add behavior.

Core Concepts

  • Controllers attach behavior to HTML elements
  • Actions respond to DOM events
  • Targets reference important elements
  • Values manage state through data attributes

Controller Design Principles

Keep Controllers Small and Reusable
// Good: Generic, reusable controller
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["content"]
  static values = { open: Boolean }

  toggle() { this.openValue = !this.openValue }

  openValueChanged() {
    this.contentTarget.classList.toggle("hidden", !this.openValue)
  }
}
Use Data Attributes for Configuration
export default class extends Controller {
  static values = {
    delay: { type: Number, default: 300 },
    event: { type: String, default: "input" }
  }

  connect() {
    this.element.addEventListener(this.eventValue, this.submit.bind(this))
  }

  submit() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => this.element.requestSubmit(), this.delayValue)
  }
}
<%= form_with data: { controller: "auto-submit", auto_submit_delay_value: 500 } %>
Compose Multiple Controllers
<div data-controller="toggle clipboard" data-toggle-open-value="false">
  <button data-action="toggle#toggle">Show</button>
  <div data-toggle-target="content" class="hidden">
    <code data-clipboard-target="source">secret-code</code>
    <button data-action="clipboard#copy">Copy</button>
  </div>
</div>

Targets and Values

Targets for Element References
export default class extends Controller {
  static targets = ["tab", "panel"]
  static values = { index: { type: Number, default: 0 } }

  select(event) { this.indexValue = this.tabTargets.indexOf(event.currentTarget) }

  indexValueChanged() {
    this.panelTargets.forEach((panel, i) => panel.classList.toggle("hidden", i !== this.indexValue))
    this.tabTargets.forEach((tab, i) => tab.setAttribute("aria-selected", i === this.indexValue))
  }
}

Action Handling

<button data-action="click->toggle#toggle">Toggle</button>
<input data-action="input->search#update focus->search#expand">
<button data-action="modal#open" data-modal-id-param="confirm-dialog">Open</button>
<input data-action="keydown.enter->form#submit keydown.escape->form#cancel">
Action Parameters
open(event) {
  const modalId = event.params.id
  document.getElementById(modalId)?.showModal()
}

Common Controller Patterns

Dropdown Controller
export default class extends Controller {
  static targets = ["menu"]
  static values = { open: Boolean }

  toggle() { this.openValue = !this.openValue }

  close(event) {
    if (!this.element.contains(event.target)) this.openValue = false
  }

  openValueChanged() {
    this.menuTarget.classList.toggle("hidden", !this.openValue)
    if (this.openValue) document.addEventListener("click", this.close.bind(this), { once: true })
  }
}
Clipboard Controller
export default class extends Controller {
  static targets = ["source", "button"]
  static values = { successMessage: { type: String, default: "Copied!" } }

  async copy() {
    const text = this.sourceTarget.value || this.sourceTarget.textContent
    await navigator.clipboard.writeText(text)
    this.showSuccess()
  }

  showSuccess() {
    const original = this.buttonTarget.textContent
    this.buttonTarget.textContent = this.successMessageValue
    setTimeout(() => this.buttonTarget.textContent = original, 2000)
  }
}

Turbo Integration

export default class extends Controller {
  connect() {
    document.addEventListener("turbo:before-visit", this.dismiss.bind(this))
    this.timeout = setTimeout(() => this.dismiss(), 5000)
  }

  disconnect() { clearTimeout(this.timeout) }
  dismiss() { this.element.remove() }
}

Architecture Patterns

Make Controllers Configurable

Externalize hardcoded values into data attributes. Never embed CSS classes, selectors, or thresholds in controller logic.

// Bad: hardcoded
export default class extends Controller {
  toggle() { this.element.classList.toggle("hidden") }
}

// Good: configurable
export default class extends Controller {
  static classes = ["toggle"]
  toggle() { this.element.classList.toggle(this.toggleClass) }
}
Mixins Over Deep Inheritance

Use mixins when behavior is shared but doesn't represent specialization.

Decision framework:

  • "is a" → inheritance (class extends BaseController)
  • "acts as" → mixin (apply behavior at connect)
  • "has a" → composition (separate controller + outlets)
// Mixin pattern
const Sortable = (controller) => {
  const original = controller.prototype.connect
  controller.prototype.connect = function() {
    if (original) original.call(this)
    this.sortable = new Sortable(this.element, this.sortableOptions)
  }
}
Targetless Controllers

If a controller mixes element-level and target-level concerns, split it. Controller acting on this.element is one responsibility; acting on targets is another.

Communicate between split controllers via custom events or outlets.

Namespaced Attributes

For flexible parameter sets without explicitly defining each value:

// Read arbitrary data-chart-* attributes
get chartOptions() {
  return Object.entries(this.element.dataset)
    .filter(([key]) => key.startsWith("chart"))
    .reduce((opts, [key, val]) => {
      opts[key.replace("chart", "").toLowerCase()] = val
      return opts
    }, {})
}

See architecture-patterns.md for SOLID principles applied to Stimulus.

Controller Communication

Choose pattern based on coupling needs:

PatternCouplingDirectionUse When
Custom eventsLooseBroadcast (1→many)Sender doesn't know receivers
OutletsStructuredDirect (1→1, 1→few)Known relationships in layout
CallbacksRead-onlyRequest/responseSharing state without triggering actions
Custom Events (Preferred Default)
// Sender
this.dispatch("submitted", { detail: { id: this.idValue }, bubbles: true })

// Receiver (in HTML)
// data-action="sender:submitted->receiver#handleSubmit"

Rules:

  • Always set bubbles: true for cross-controller events
  • Namespace event names: form:submitted, cart:updated
  • Document the detail contract
Outlets (Structured Relationships)
export default class extends Controller {
  static outlets = ["result"]

  search() {
    const results = this.performSearch()
    this.resultOutlets.forEach(outlet => outlet.update(results))
  }

  resultOutletConnected(outlet) { /* setup */ }
  resultOutletDisconnected(outlet) { /* cleanup */ }
}

Lifecycle Best Practices

Don't Overuse connect()

connect() is for third-party plugin initialization only. Not for state setup (use Values API) or event listeners (use data-action).

// Good: plugin init in connect
connect() {
  this.chart = new Chart(this.canvasTarget, this.chartConfig)
}

disconnect() {
  this.chart.destroy()
  this.chart = null
}
Always Pair connect/disconnect

Every resource acquired in connect() must be released in disconnect(). Controllers can connect/disconnect multiple times during Turbo navigation.

Turbo Cache Teardown

Prevent "flash of manipulated content" when cached pages return:

connect() {
  document.addEventListener("turbo:before-cache", this.teardown.bind(this))
  this.slider = new Swiper(this.element, this.config)
}

teardown() {
  this.slider?.destroy()
  // Restore original DOM state before caching
}

disconnect() {
  this.teardown()
}

Event Listener Hygiene

Store Bound References

.bind() creates a new function each call. Store the reference for proper removal:

connect() {
  this.boundResize = this.resize.bind(this)
  window.addEventListener("resize", this.boundResize, { passive: true })
}

disconnect() {
  window.removeEventListener("resize", this.boundResize)
}
Prefer Declarative Actions
<%# Good: Stimulus manages lifecycle %>
<div data-controller="search"
     data-action="resize@window->search#layout keydown.escape@window->search#close">

<%# Bad: manual addEventListener in connect() %>

Global events use @window or @document suffix in data-action.

See lifecycle-and-events.md for complete patterns.

Application Controller

Create app/javascript/controllers/application_controller.js as a base for shared functionality:

import { Controller } from "@hotwired/stimulus"

export default class ApplicationController extends Controller {
  handleError(error, context = {}) {
    console.error(`[${this.identifier}]`, error, context)
    // Sentry.captureException(error, { extra: context })
  }
}

Extend it in domain controllers:

import ApplicationController from "./application_controller"

export default class extends ApplicationController {
  async save() {
    try {
      await this.persist()
    } catch (error) {
      this.handleError(error, { action: "save", id: this.idValue })
    }
  }
}

Rules:

  • Use try-catch for async operations and third-party library calls
  • Never swallow errors — log or report via handleError()
  • Use requestSubmit() not submit() for forms — fires validation and Turbo intercept

Anti-Patterns

Anti-PatternProblemSolution
Creating DOM extensivelyFighting Stimulus philosophyLet server render HTML
Storing state in JSState lost on navigationUse Values in HTML
Over-specific controllersNot reusableDesign generic behaviors
Manual querySelectorFragile, bypasses StimulusUse targets
Inline event handlersUnmaintainableUse data-action
Overloading connect()Bloated, mixes concernsValues for state, data-action for events
Tight controller couplingFragile, hard to testCustom events or outlets
Missing disconnect cleanupMemory leaks, duplicate listenersAlways pair connect/disconnect
Unbound event referencesCan't removeEventListenerStore .bind() result

Output Format

When creating Stimulus controllers, provide:

  1. Controller - Complete JavaScript implementation
  2. HTML Example - Sample markup showing usage
  3. Configuration - Available values and targets
  4. Integration - How it works with Turbo if applicable

Comments

Markdown supported