import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["checkbox", "selectAll", "count", "submit"]
static values = { selectedIds: Array }
connect() {
this.refresh()
this.element.addEventListener("turbo:submit-start", this.serialize)
}
disconnect() {
this.element.removeEventListener("turbo:submit-start", this.serialize)
}
toggle() {
this.refresh()
}
toggleAll(event) {
const checked = event.target.checked
this.checkboxTargets.forEach((box) => { box.checked = checked })
this.refresh()
}
refresh() {
const selected = this.checkboxTargets.filter((box) => box.checked)
this.selectedIdsValue = selected.map((box) => box.value)
const total = this.checkboxTargets.length
this.selectAllTarget.checked = selected.length === total && total > 0
this.selectAllTarget.indeterminate =
selected.length > 0 && selected.length < total
this.countTarget.textContent = `${selected.length} selected`
this.submitTarget.disabled = selected.length === 0
}
serialize = () => {
this.element
.querySelectorAll("input[data-batch-id]")
.forEach((node) => node.remove())
this.selectedIdsValue.forEach((id) => {
const field = document.createElement("input")
field.type = "hidden"
field.name = "ids[]"
field.value = id
field.setAttribute("data-batch-id", "")
this.element.appendChild(field)
})
}
}
<%= form_with url: batch_documents_path, method: :post,
data: { controller: "bulk-select", turbo_stream: true } do %>
<div class="toolbar">
<span data-bulk-select-target="count">0 selected</span>
<button type="submit" name="action_type" value="archive"
data-bulk-select-target="submit">
Archive selected
</button>
</div>
<table>
<thead>
<tr>
<th>
<input type="checkbox"
data-bulk-select-target="selectAll"
data-action="bulk-select#toggleAll">
</th>
<th>Title</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
<% @documents.each do |document| %>
<tr id="<%= dom_id(document) %>">
<td>
<input type="checkbox"
value="<%= document.id %>"
data-bulk-select-target="checkbox"
data-action="bulk-select#toggle">
</td>
<td><%= document.title %></td>
<td><%= time_ago_in_words(document.updated_at) %> ago</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<% @archived.each do |document| %>
<%= turbo_stream.remove dom_id(document) %>
<% end %>
<%= turbo_stream.update "flash" do %>
<div class="flash notice">
<%= pluralize(@archived.size, "document") %> archived
</div>
<% end %>
<%= turbo_stream.update "selection-count" do %>
0 selected
<% end %>
class DocumentsController < ApplicationController
def index
@documents = current_account.documents.active.order(updated_at: :desc)
end
def batch
ids = Array(params[:ids]).map(&:to_i).reject(&:zero?)
scope = current_account.documents.where(id: ids)
@archived =
case params[:action_type]
when "archive" then scope.to_a.each(&:archive!)
else []
end
respond_to do |format|
format.turbo_stream
format.html { redirect_to documents_path, notice: "Done" }
end
end
end
This snippet shows the classic "select many rows, then act on all of them" table interaction built with Hotwire, without a single line of hand-written AJAX. The state lives entirely in the DOM: checkboxes hold the selected IDs, and a Stimulus controller keeps the header "select all" box, a count badge, and a hidden batch form in sync.
In the bulk_select_controller.js tab, the controller declares checkbox, selectAll, count, and submit targets plus a selectedIds value. The toggle() action recomputes derived UI whenever a row box changes, while toggleAll() mirrors the header box down to every row. The key helper is refresh(): it collects the checked boxes into selectedIdsValue, updates the badge text, sets the header box to an indeterminate state when the selection is partial, and disables the submit button when nothing is chosen. Storing IDs in a Stimulus value (rather than a private field) means the state is observable and could be read by other controllers.
When the batch form submits, serialize() runs on turbo:submit-start to inject one hidden <input name="ids[]"> per selected ID. This keeps the markup clean until submit time and avoids maintaining a parallel set of hidden fields as the user clicks around. Because the form carries data-turbo-stream, Turbo sends the request expecting a Turbo Stream response instead of a full navigation.
The index.html.erb tab wires everything together with data-controller, target attributes, and action descriptors. The <form> posts to a batch route, and each row exposes its ID through data-bulk-select-id-param.
The documents/batch.turbo_stream.erb tab is what closes the loop: the controller action archives the selected records and re-renders. It emits multiple turbo_stream actions in one response — removing each processed row and replacing the flash — so the browser patches only the affected DOM nodes. This pattern scales well because the server owns the rendering, the client owns transient selection state, and no JSON serialization or manual DOM diffing is needed. The main pitfall to watch is resetting selectedIdsValue and the header checkbox after a successful batch, since removed rows won't fire their own change events.
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.