javascript erb ruby 125 lines · 4 tabs

Stimulus: bulk selection + Turbo batch action

Shared by codesnips Jan 2026
4 tabs
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)
    })
  }
}
4 files · javascript, erb, ruby Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
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

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<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)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Stimulus: bulk selection + Turbo batch action — share card
Link copied