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.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.