import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "counter"]
static values = {
max: { type: Number, default: 280 }
}
connect() {
this.updateCounter()
}
updateCounter() {
const length = this.inputTarget.value.length
const remaining = this.maxValue - length
this.counterTarget.textContent = `${remaining} characters remaining`
// Add warning class when close to limit
this.counterTarget.classList.toggle('text-warning', remaining < 50 && remaining > 0)
this.counterTarget.classList.toggle('text-danger', remaining <= 0)
}
}
<%= form_with model: @post, data: { controller: "character-counter", character_counter_max_value: 280 } do |f| %>
<div class="form-group">
<%= f.label :title %>
<%= f.text_area :title,
rows: 3,
data: {
character_counter_target: "input",
action: "input->character-counter#updateCounter"
},
class: "form-control" %>
<div data-character-counter-target="counter" class="text-sm text-gray-600 mt-1"></div>
</div>
<%= f.submit class: "btn btn-primary" %>
<% end %>
Stimulus brings just enough JavaScript to make static Rails views interactive while staying close to the HTML. Controllers connect to DOM elements via data-controller, and actions bind to events with data-action. I use Stimulus for client-side validations, dynamic field visibility, and character counters—things that don't warrant a full page reload but need interactivity. Targets provide typed references to important elements, and values allow passing data from the server. The convention-based approach means I rarely write initialization boilerplate. Stimulus works seamlessly with Turbo, automatically connecting and disconnecting controllers as frames update. For complex state management I still reach for React, but Stimulus handles 80% of UI interactions beautifully.