Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Practical Hotwire: Stimulus & Turbo on the Ground

Avatar for Jeremy Smith Jeremy Smith
September 29, 2026

Practical Hotwire: Stimulus & Turbo on the Ground

Curious about Stimulus and Turbo? Already using Hotwire, but seeking out solid practices? Join this guided tour through a variety of examples and techniques, with commentary on the challenges and tradeoffs. Keeping an eye out for sharp edges, we'll discover what makes HTML-over-the-wire magical.

Avatar for Jeremy Smith

Jeremy Smith

September 29, 2026

More Decks by Jeremy Smith

Other Decks in Programming

Transcript

  1. When did you learn not to send an email in

    an after_create callback?
  2. When you spot a dependent: :destroy, why are you wondering:

    "yeah, but how many in that has_many"?
  3. ```patch - Fork around and find out. + Take a

    bite and feel the burn. ```
  4. Take a bite when you write some code. Feel the

    burn when you deploy to production.
  5. Take a bite when you architect a subsystem. Feel the

    burn when you come back a year later and can’t understand it.
  6. Take a bite when you try a technique you found

    in a blog post. Feel the burn when you discover it only works well in one situation, and not the one you’re in.
  7. import { Controller } from "@hotwired/stimulus" export default class extends

    Controller { static targets = [ "button", "menu" ] connect() { this.boundToggle = this.toggle.bind(this) this.boundHideOutside = this.hideOutside.bind(this) this.boundHideOnEscape = this.hideOnEscape.bind(this) this.buttonTarget.addEventListener("click", this.boundToggle) document.addEventListener("click", this.boundHideOutside) this.element.addEventListener("keydown", this.boundHideOnEscape) } disconnect() { this.buttonTarget.removeEventListener("click", this.boundToggle) document.removeEventListener("click", this.boundHideOutside) this.element.removeEventListener("keydown", this.boundHideOnEscape) }
  8. import { Controller } from "@hotwired/stimulus" export default class extends

    Controller { static targets = [ "button", "menu" ] connect() { this.boundToggle = this.toggle.bind(this) this.boundHideOutside = this.hideOutside.bind(this) this.boundHideOnEscape = this.hideOnEscape.bind(this) this.buttonTarget.addEventListener("click", this.boundToggle) document.addEventListener("click", this.boundHideOutside) this.element.addEventListener("keydown", this.boundHideOnEscape) } disconnect() { this.buttonTarget.removeEventListener("click", this.boundToggle) document.removeEventListener("click", this.boundHideOutside) this.element.removeEventListener("keydown", this.boundHideOnEscape) }
  9. // app/javascript/controllers/profile_bio_controller.js import { Controller } from "@hotwired/stimulus" export default

    class extends Controller { static targets = [ "input", "counter", "status" ] connect() { this.updateCounter() this.resizeInput() } update() { this.updateCounter() this.resizeInput() this.scheduleSave() } updateCounter() { ... } resizeInput() { ... } scheduleSave() { ... } // ...
  10. import { Controller } from "@hotwired/stimulus" export default class extends

    Controller { static targets = [ "input", "counter", "status" ] scheduleSave() { clearTimeout(this.saveTimer) this.saveTimer = setTimeout( () => this.element.requestSubmit(), 500 ) } connect() { this.updateCounter() this.resizeInput() } update() { this.updateCounter() this.resizeInput() this.scheduleSave() } saveStarted() { clearTimeout(this.statusTimer) this.saveStartedAt = Date.now() this.statusTarget.textContent = "Saving..." this.statusTarget.classList.add("text-gray-500") this.statusTarget.classList.remove("text-green-600", "text-red-600") updateCounter() { const length = this.inputTarget.value.length this.counterTarget.textContent = `${length} / 280` } saveFinished(event) { const message = event.detail.success ? "Saved" : "Failed to save" const color = event.detail.success ? "text-green-600" : "text-red-600" const remaining = Math.max(800 - (Date.now() - this.saveStartedAt), 0) if (length > 280) { this.counterTarget.classList.add("text-red-600") this.counterTarget.classList.remove("text-gray-500") } else { this.counterTarget.classList.add("text-gray-500") this.counterTarget.classList.remove("text-red-600") } this.statusTimer = setTimeout(() => { this.statusTarget.textContent = message this.statusTarget.classList.add(color) this.statusTarget.classList.remove("text-gray-500") }, remaining) } resizeInput() { this.inputTarget.style.height = "auto" this.inputTarget.style.height = `${this.inputTarget.scrollHeight}px` } } }
  11. <%= form_with model: @profile, url: pitfalls_profile_path(@profile), data: { controller: "profile-bio",

    action: "turbo:submit-start->profile-bio#saveStarted turbo:submit-end->profile-bio#saveFinished" } do |form| %> <%= form.label :bio %> <%= form.text_area :bio, rows: 3 data: { profile_bio_target: "input", action: "input->profile-bio#update" } %> <div> <span data-profile-bio-target="counter"></span> <span data-profile-bio-target="status"></span> </div> <% end %>
  12. import { Controller } from "@hotwired/stimulus" import { useDebounce }

    from "stimulus-use" export default class extends Controller { static targets = [ "status" ] static classes = [ "saved", "failed" ] static values = { minimumSavingDuration: { type: Number, default: 800 } } static debounces = [ "save" ] connect() { useDebounce(this, { wait: 500 }) } disconnect() { clearTimeout(this.statusTimer) } save() { this.element.requestSubmit() } saving() { clearTimeout(this.statusTimer) this.savingStartedAt = Date.now() this.statusTarget.textContent = "Saving..." this.statusTarget.classList.remove(this.savedClass, this.failedClass) }
  13. import { Controller } from "@hotwired/stimulus" export default class extends

    Controller { connect() { this.resize() } resize() { this.element.style.height = "auto" this.element.style.height = `${this.element.scrollHeight}px` } }
  14. import { Controller } from "@hotwired/stimulus" export default class extends

    Controller { static targets = [ "input", "counter" ] static classes = [ "exceeded" ] static values = { limit: Number } connect() { this.render() } render() { this.counterTarget.textContent = `${this.count} / ${this.limitValue}` this.counterTarget.classList.toggle(this.exceededClass, this.exceeded) } get count() { return this.inputTarget.value.length } get exceeded() { return this.count > this.limitValue } }
  15. import { Controller } from "@hotwired/stimulus" export default class extends

    Controller { static targets = [ "input", "preview" ] if (invalid.length > 0) { html += ` <h3 Invalid addresses (${invalid.length})</h3> <div>${invalid.map((entry) => this.chip(entry)).join("")}</div>` } check() { const entries = this.inputTarget.value .split(/[,;\n]+/) .map((part) => part.trim()) .filter(Boolean) .flatMap((part) => { const match = part.match(/^(.*)<([^>]*)>$/) if (match) { const name = match[1].trim() || null const email = match[2].trim().toLowerCase() return [ { name, email } ] } else { return part.split(/\s+/).map((address) => { return { name: null, email: address.toLowerCase() } }) } }) if (duplicates > 0) { const noun = duplicates === 1 ? "duplicate" : "duplicates" html += `<p>${duplicates} ${noun} removed</p>` } this.previewTarget.innerHTML = html } chip(entry) { const name = entry.name ? `<strong>${this.escapeHtml(entry.name)}</strong> ` : "" const email = this.escapeHtml(entry.email) const seen = new Set() const deduped = entries.filter((entry) => { return !seen.has(entry.email) && seen.add(entry.email) }) const valid = deduped.filter((entry) => { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(entry.email) }) const invalid = deduped.filter((entry) => !valid.includes(entry)) const duplicates = entries.length - deduped.length return `<span>${name}${email}</span>` } escapeHtml(text) { return text .replaceAll("&", .replaceAll("<", .replaceAll(">", .replaceAll('"', } let html = "" } > > if (valid.length > 0) { html += ` <h3 Valid addresses (${valid.length})</h3> <div>${valid.map((entry) => this.chip(entry)).join("")}</div>` } "&amp;") "&lt;") "&gt;") "&quot;")
  16. class Subscriber::Email < Data.define(:name, :email) List = Data.define(:valid, :invalid, :duplicates_removed)

    SEPARATORS = /[,;\n]+/ NAMED_ADDRESS = /\A(?<name>.*)<(?<email>[^>]*)>\z/ def initialize(name: nil, email:) super(name: name&.strip.presence, email: email.strip.downcase) end def self.parse_list(blob) emails = blob.to_s.split(SEPARATORS).flat_map { parse_part(it) } unique = emails.uniq(&:email) valid, invalid = unique.partition(&:valid?) duplicates_removed = emails.size - unique.size List.new(valid:, invalid:, duplicates_removed:) end def self.parse_part(part) if (match = part.strip.match(NAMED_ADDRESS)) [ new(name: match[:name], email: match[:email]) ] else part.split.map { |email| new(email:) } end end def valid? = email.match?(URI::MailTo::EMAIL_REGEXP) end
  17. # app/controllers/subscriber_import_previews_controller.rb class SubscriberImportPreviewsController < ApplicationController def create @result =

    Subscriber::Email.parse_list(params[:emails]) end end <%# app/views/subscriber_import_previews/create.turbo_stream.erb %> <%= turbo_stream.update "subscriber_import_preview" do %> <%= render "subscriber_imports/preview", result: @result %> <% end %>
  18. <%# app/views/subscriber_imports/new.html.erb %> <%= form_with url: subscriber_imports_path do |form| %>

    <%= form.label :emails, "Email addresses" %> <%= form.text_area :emails, value: @emails, rows: 8 %> <%= form.button "Check list", formaction: subscriber_import_preview_path %> <div id="subscriber_import_preview"></div> <%= form.button "Add subscribers" %> <% end %>
  19. <%# app/views/subscriber_imports/new.html.erb %> <%= form_with url: subscriber_imports_path do |form| %>

    <%= form.label :emails, "Email addresses" %> <%= form.text_area :emails, value: @emails, rows: 8 %> <%= form.button "Check list", formaction: subscriber_import_preview_path %> <div id="subscriber_import_preview"></div> <%= form.button "Add subscribers" %> <% end %>
  20. import { Controller } from "@hotwired/stimulus" import flatpickr from "flatpickr"

    export default class extends Controller { connect() { flatpickr(this.element, { inline: true }) } }
  21. import { Controller } from "@hotwired/stimulus" import flatpickr from "flatpickr"

    export default class extends Controller { connect() { this.picker = flatpickr(this.element, { inline: true }) } disconnect() { this.picker.destroy() } }
  22. // app/javascript/controllers/confirm_controller.js import { Controller } from "@hotwired/stimulus" export default

    class extends Controller { static targets = [ "trigger", "confirmation" ] static classes = [ "hide" ] disconnect() { this.disarm() } arm() { this.triggerTarget.classList.add(this.hideClass) this.confirmationTarget.classList.remove(this.hideClass) } disarm() { this.triggerTarget.classList.remove(this.hideClass) this.confirmationTarget.classList.add(this.hideClass) } }
  23. // app/javascript/controllers/confirm_controller.js import { Controller } from "@hotwired/stimulus" export default

    class extends Controller { static targets = [ "trigger", "confirmation" ] static classes = [ "hide" ] disconnect() { this.disarm() } arm() { this.triggerTarget.classList.add(this.hideClass) this.confirmationTarget.classList.remove(this.hideClass) } disarm() { this.triggerTarget.classList.remove(this.hideClass) this.confirmationTarget.classList.add(this.hideClass) } }
  24. <%# app/views/layouts/application.html.erb %> <body data-turbo-prefetch="false"> <%# app/views/products/index.html.erb %> <%= render

    partial: "product", collection: @featured, locals: { prefetch: true } %> <%# app/views/products/_product.html.erb %> <%= link_to product_path(product), data: ({ turbo_prefetch: true } if prefetch) do %>
  25. <%# app/views/layouts/application.html.erb %> <body data-turbo-prefetch="false"> <%# app/views/products/index.html.erb %> <%= render

    partial: "product", collection: @featured, locals: { prefetch: true } %> <%# app/views/products/_product.html.erb %> <%= link_to product_path(product), data: ({ turbo_prefetch: true } if prefetch) do %>
  26. class ProductsController < ApplicationController def show @product = Product.find(params[:id]) @product.increment!(:views_count)

    unless prefetch_request? end private - def prefetch_request? request.headers["X Sec-Purpose"] == "prefetch" end end
  27. class ProductsController < ApplicationController def show @product = Product.find(params[:id]) @product.increment!(:views_count)

    unless prefetch_request? end private - def prefetch_request? request.headers["X Sec-Purpose"] == "prefetch" end end
  28. <%# app/views/submissions/index.html.erb %> <ul> <% @submissions.each do |submission| %> <li>

    <%= link_to submission.title, submission_path(submission), data: { turbo_frame: "submission_panel" } %> </li> <% end %> </ul> <%= turbo_frame_tag "submission_panel" do %> <p>Select a submission from the queue.</p> <% end %>
  29. <%# app/views/submissions/index.html.erb %> <ul> <% @submissions.each do |submission| %> <li>

    <%= link_to submission.title, submission_path(submission), data: { turbo_frame: "submission_panel" } %> </li> <% end %> </ul> <%= turbo_frame_tag "submission_panel" do %> <p>Select a submission from the queue.</p> <% end %>
  30. <%# app/views/submissions/show.html.erb %> <% if @submission.digest? %> <%= render "digest",

    submission: @submission %> <% else %> <%= turbo_frame_tag "submission_panel" do %> <h2><%= @submission.title %></h2> <p>Submitted by <%= @submission.author %></p> <p><%= @submission.summary %></p> <% end %> <% end %> <%# app/views/pitfalls/submissions/_digest.html.erb %> <%# Digest detail view added later, missing frame tag. %> <h2><%= submission.title %></h2> <p>Generated automatically</p> <p><%= submission.summary %></p>
  31. <%# app/views/submissions/show.html.erb %> <% if @submission.digest? %> <%= render "digest",

    submission: @submission %> <% else %> <%= turbo_frame_tag "submission_panel" do %> <h2><%= @submission.title %></h2> <p>Submitted by <%= @submission.author %></p> <p><%= @submission.summary %></p> <% end %> <% end %> <%# app/views/pitfalls/submissions/_digest.html.erb %> <%# Digest detail view added later, missing frame tag. %> <h2><%= submission.title %></h2> <p>Generated automatically</p> <p><%= submission.summary %></p>
  32. <%# app/views/errors/not_found.html.erb %> <%# The 404 page for the app.

    %> <% content_for :title, "Not found" %> <div> <h1 We couldn't find that</h1> > <p> It may have been removed by someone else on your team. </p> </div>
  33. <%# app/views/sessions/new.html.erb %> <%# The sign-in page every expired session

    redirects to. %> <% content_for :title, "Sign in" %> <div> <h1 Sign in</h1> <p>Your session expired. Sign in to continue.</p> > <div> <input type="email" placeholder="[email protected]"> <%= button_to "Sign in", sign_in_submit_path %> </div> </div>
  34. <div data-controller="frame-recovery" data-frame-recovery-hidden-class="hidden" data-action="turbo:frame-missing->frame-recovery#missing turbo:frame-render->frame-recovery#loaded"> <%= turbo_frame_tag "submission_panel", data: {

    frame_recovery_target: "frame" } do %> <p>Select a submission from the queue.</p> <% end %> <div data-frame-recovery-target="error"> <p>We couldn't load that submission.</p> <p>It may have been removed by someone else on your team.</p> <%= link_to "Reload the queue", refactored_submissions_path %> </div> </div>
  35. <div data-controller="frame-recovery" data-frame-recovery-hidden-class="hidden" data-action="turbo:frame-missing->frame-recovery#missing turbo:frame-render->frame-recovery#loaded"> <%= turbo_frame_tag "submission_panel", data: {

    frame_recovery_target: "frame" } do %> <p>Select a submission from the queue.</p> <% end %> <div data-frame-recovery-target="error"> <p>We couldn't load that submission.</p> <p>It may have been removed by someone else on your team.</p> <%= link_to "Reload the queue", refactored_submissions_path %> </div> </div>
  36. import { Controller } from "@hotwired/stimulus" export default class extends

    Controller { static targets = [ "frame", "error" ] static classes = [ "hidden" ] missing(event) { event.preventDefault() const { response, visit } = event.detail this.report(event.target, response) if (response.redirected) { visit(response) } else { this.frameTarget.classList.add(this.hiddenClass) this.errorTarget.classList.remove(this.hiddenClass) } } // ...
  37. import { Controller } from "@hotwired/stimulus" export default class extends

    Controller { static targets = [ "frame", "error" ] static classes = [ "hidden" ] missing(event) { event.preventDefault() const { response, visit } = event.detail this.report(event.target, response) if (response.redirected) { visit(response) } else { this.frameTarget.classList.add(this.hiddenClass) this.errorTarget.classList.remove(this.hiddenClass) } } // ...
  38. // ... report(frame, response) { const report = { frame:

    frame.id, status: response.status, url: response.url } if (response.ok && !response.redirected) { report.likelyCause = "200 with no matching frame, response missing turbo_frame_tag wrapper" } console.error("[frame-missing]", report) } }
  39. <%# app/views/pipeline_runs/index.html.erb %> <% @runs.each do |run| %> <div> <%=

    render "summary", run: run %> <%= turbo_frame_tag dom_id(run), src: pipeline_run_path(run) do %> <p>Loading detail...</p> <% end %> </div> <% end %> <%# app/views/pipeline_runs/show.html.erb %> <%= turbo_frame_tag dom_id(@run) do %> <%= render "detail", run: @run %> <% end %>
  40. # app/controllers/refactored/pipeline_runs_controller.rb @runs = PipelineRun.includes(:stages, :artifacts).order(number: :desc) <%# app/views/pipeline_runs/index.html.erb %>

    <% @runs.each do |run| %> <div> <%= render "summary", run: run %> <div> <%= render "detail", run: run %> </div> </div> <% end %>
  41. <%# app/views/accounts/show.html.erb %> <%= turbo_frame_tag dom_id(account) do %> <div>Name</div> <div><%=

    account.name %></div> <%= link_to "Edit", edit_account_path(account) %> <% end %> <%# app/views/accounts/edit.html.erb %> <%= turbo_frame_tag dom_id(account) do %> <%= form_with model: account do |form| %> <%= form.label :name %> <%= form.text_field :name %> <%= form.button "Save" %> <%= link_to "Cancel", account_path(account) %> <% end %> <% end %>
  42. <%# app/views/accounts/show.html.erb %> <%= turbo_frame_tag dom_id(account) do %> <div>Name</div> <div><%=

    account.name %></div> <%= link_to "Edit", edit_account_path(account) %> <% end %> <%# app/views/accounts/edit.html.erb %> <%= turbo_frame_tag dom_id(account) do %> <%= form_with model: account do |form| %> <%= form.label :name %> <%= form.text_field :name %> <%= form.button "Save" %> <%= link_to "Cancel", account_path(account) %> <% end %> <% end %>
  43. <%# app/views/accounts/show.html.erb %> <%= turbo_frame_tag dom_id(account) do %> <div>Name</div> <div><%=

    account.name %></div> <%= link_to "Edit", edit_account_path(account) %> <%= turbo_stream.replace dom_id(@account, :meter) do %> <%= render "meter", account: @account %> <% end %> <%= turbo_stream.replace dom_id(@account, :checklist) do %> <%= render "checklist", account: @account %> <% end %> <% end %>
  44. <%# app/views/accounts/show.html.erb %> <%= turbo_frame_tag dom_id(account) do %> <div>Name</div> <div><%=

    account.name %></div> <%= link_to "Edit", edit_account_path(account) %> <%= turbo_stream.replace dom_id(@account, :meter) do %> <%= render "meter", account: @account %> <% end %> <%= turbo_stream.replace dom_id(@account, :checklist) do %> <%= render "checklist", account: @account %> <% end %> <% end %>
  45. <%# app/views/accounts/update.turbo_stream.erb %> <%= turbo_stream.replace dom_id(@account, @section.to_sym), partial: "accounts/section", locals:

    { account: @account, section: @section } %> <%= turbo_stream.replace dom_id(@account, :meter), partial: "accounts/meter", locals: { account: @account } %> <%= turbo_stream.replace dom_id(@account, :checklist), partial: "accounts/checklist", locals: { account: @account } %> <%= turbo_stream.replace dom_id(@account, :banner), partial: "accounts/banner", locals: { account: @account } %>
  46. <%# app/views/carts/show.html.erb %> <div id="sidebar_totals"> <%= render "totals", cart: @cart

    %> </div> <%# app/views/cart_items/update.turbo_stream.erb %> <%= turbo_stream.replace "sidbar_totalls" do %> <%= render "totals", cart: @cart %> <% end %>
  47. <%# app/views/carts/show.html.erb %> <div id="sidebar_totals"> <%= render "totals", cart: @cart

    %> </div> <%# app/views/cart_items/update.turbo_stream.erb %> <%= turbo_stream.replace "sidbar_totalls" do %> <%= render "totals", cart: @cart %> <% end %>
  48. <%# app/views/carts/show.html.erb %> <div id="sidebar_totals"> sidebar_totals <%= render "totals", cart:

    @cart %> </div> <%# app/views/cart_items/update.turbo_stream.erb %> <%= turbo_stream.replace "sidbar_totalls" sidbar_totalls do %> <%= render "totals", cart: @cart %> <% end %>
  49. <%# app/views/tasks/_task.html.erb %> <div id="<%= dom_id(task) %>"> <span class="<%= "line-through"

    if task.completed? %>"><%= task.title %></span> <%# app/views/tasks/_pinned_card.html.erb %> <div id="<%= dom_id(task) %>"> <p><%= task.title %></p> <%= task.completed? ? "Done" : "Not done" %> <%# app/views/tasks/update.turbo_stream.erb %> <%= turbo_stream.replace dom_id(@task), partial: "tasks/task", locals: { task: @task } %>
  50. <%# app/views/tasks/_task.html.erb %> <div id="<%= dom_id(task) %>"> <span class="<%= "line-through"

    if task.completed? %>"><%= task.title %></span> <%# app/views/tasks/_pinned_card.html.erb %> <div id="<%= dom_id(task) %>"> <p><%= task.title %></p> <%= task.completed? ? "Done" : "Not done" %> <%# app/views/tasks/update.turbo_stream.erb %> <%= turbo_stream.replace dom_id(@task), partial: "tasks/task", locals: { task: @task } %>
  51. <%# app/views/tasks/_task.html.erb %> <div id="<%= dom_target(task, :row) %>"> <span class="<%=

    "line-through" if task.completed? %>"><%= task.title %></span> <%# app/views/tasks/_pinned_card.html.erb %> <div id="<%= dom_target(task, :pinned_card) %>"> <p><%= task.title %></p> <%= task.completed? ? "Done" : "Not done" %> <%# app/views/tasks/update.turbo_stream.erb %> <%= turbo_stream.replace dom_target(@task, :row), partial: "tasks/task", locals: { task: @task } %> <% if @task.pinned? %> <%= turbo_stream.replace dom_target(@task, :pinned_card), partial: "tasks/pinned_card", locals: { task: @task } %>
  52. <%# app/views/tasks/_task.html.erb %> <div id="<%= dom_target(task, :row) %>"> <span class="<%=

    "line-through" if task.completed? %>"><%= task.title %></span> <%# app/views/tasks/_pinned_card.html.erb %> <div id="<%= dom_target(task, :pinned_card) %>"> <p><%= task.title %></p> <%= task.completed? ? "Done" : "Not done" %> <%# app/views/tasks/update.turbo_stream.erb %> <%= turbo_stream.replace dom_target(@task, :row), partial: "tasks/task", locals: { task: @task } %> <% if @task.pinned? %> <%= turbo_stream.replace dom_target(@task, :pinned_card), partial: "tasks/pinned_card", locals: { task: @task } %>
  53. class Order < ApplicationRecord broadcasts inserts_by: :prepend after_update_commit -> {

    broadcast_prepend_to "pickup", target: "pickup", partial: "orders/pickup" }, if: -> { saved_change_to_ready? && ready? } end <%# app/views/orders/_order.html.erb %> <%= turbo_stream_from(order) %> <tr id="<%= dom_id(order) %>"> <td><%= order.item %></td> <td><%= order.name %></td> </tr> <%# app/views/orders/_pickup.html.erb %> <div>Pickup for <%= order.name %> <%= order.item %></div>
  54. class Order < ApplicationRecord broadcasts inserts_by: :prepend after_update_commit -> {

    broadcast_prepend_to "pickup", target: "pickup", partial: "orders/pickup" }, if: -> { saved_change_to_ready? && ready? } end <%# app/views/orders/_order.html.erb %> <%= turbo_stream_from(order) %> <tr id="<%= dom_id(order) %>"> <td><%= order.item %></td> <td><%= order.name %></td> </tr> <%# app/views/orders/_pickup.html.erb %> <div>Pickup for <%= order.name %> <%= order.item %></div>
  55. class Order < ApplicationRecord broadcasts inserts_by: :prepend after_update_commit -> {

    broadcast_prepend_to "pickup", target: "pickup", partial: "orders/pickup" }, if: -> { saved_change_to_ready? && ready? } end <%# app/views/orders/_order.html.erb %> <%= turbo_stream_from(order) %> <tr id="<%= dom_id(order) %>"> <td><%= order.item %></td> <td><%= order.name %></td> </tr> <%# app/views/orders/_pickup.html.erb %> <div>Pickup for <%= order.name %> <%= order.item %></div>
  56. class Order < ApplicationRecord belongs_to :cafe def broadcast_created broadcast_prepend_to [

    cafe, :bar ], target: dom_target(cafe, :bar), partial: "orders/order" end def broadcast_ready broadcast_replace_to [ cafe, :bar ], target: dom_target(self, :row), partial: "orders/order" broadcast_prepend_to [ cafe, :pickup ], target: dom_target(cafe, :pickup), partial: "orders/pickup" end end
  57. class Order < ApplicationRecord belongs_to :cafe def broadcast_created broadcast_prepend_to [

    cafe, :bar ] ], target: dom_target(cafe, :bar), partial: "orders/order" end def broadcast_ready broadcast_replace_to [ cafe, :bar ] ], target: dom_target(self, :row), partial: "orders/order" broadcast_prepend_to [ cafe, :pickup ] ], target: dom_target(cafe, :pickup), partial: "orders/pickup" end end
  58. class Order < ApplicationRecord broadcasts inserts_by: :prepend after_update_commit -> {

    broadcast_prepend_to "pickup", target: "pickup", partial: "orders/pickup" }, if: -> { saved_change_to_ready? && ready? } end
  59. class Order < ApplicationRecord # broadcasts inserts_by: :prepend # after_update_commit

    -> { # broadcast_prepend_to "pickup", target: "pickup", partial: "orders/pickup" # }, if: -> { saved_change_to_ready? && ready? } def broadcast_ready broadcast_replace_to [ cafe, :bar ], target: dom_target(self, :row), partial: "orders/order" broadcast_prepend_to [ cafe, :pickup ], target: dom_target(cafe, :pickup), partial: "orders/pickup" end end
  60. class Order < ApplicationRecord def broadcast_ready # ... end def

    mark_ready return false if ready? update!(ready: true) end end class OrdersController < ApplicationController def update order = Order.find(params[:id]) order.broadcast_ready if order.mark_ready head :no_content end end
  61. [AnswerQuestionJob] [2f6b9a74] [ActionCable] Broadcasting to Z2lkOi8vcHJhY3RpY2FsLWhvdHdpcmUvR2FtZS82Nw: "<turbo-stream action=\"replace\" target=\"game_67_banner\"><template>... [AnswerQuestionJob]

    [2f6b9a74] Player Load (0.1ms) SELECT "players".* FROM "players" WHERE "players"."id" = 68 LIMIT 1 [AnswerQuestionJob] [2f6b9a74] ↳ app/views/questions/_question.html.erb:2 [AnswerQuestionJob] [2f6b9a74] Rendered questions/_question.html.erb (Duration: 3.3ms | GC: 0.0ms) [AnswerQuestionJob] [2f6b9a74] Error performing AnswerQuestionJob (Job ID: 2f6b9a74) from Async(default) in 2527.33ms: ActionView::Template::Error (undefined method 'time_zone' for nil): app/views/questions/_question.html.erb:6 app/jobs/answer_question_job.rb:24:in 'AnswerQuestionJob#perform'
  62. <%= turbo_stream_from @game %> <div> <h1 20 Agentic Questions</h1> <div>

    Signed in as <span><%= Current.player.name %></span> </div> </div> <%= render "games/banner", game: @game %> > <div id="<%= dom_target(@game, :questions) %>"> <%= render partial: "questions/question", collection: @questions %> </div>
  63. <%= turbo_stream_from @game %> <div> <h1 <div> Signed in as

    <span><%= Current.player.name %></span> </div> </div> <%= render "games/banner", game: @game %> <div id="<%= dom_target(@game, :questions) %>"> <%= render partial: "questions/question", collection: @questions %> </div>
  64. <%# app/views/questions/_question.html.erb %> <div id="<%= dom_target(question, :row) %>"> <div> Question

    <%= question.number %> <%= question.created_at.in_time_zone(Current.player.time_zone) .strftime("%l:%M %p %Z") %> </div> <p> <%= question.player == Current.player ? "You" : question.player.name %>: <%= question.body %> </p> <% if question.pending? %> <p>Agent: Thinking...</p> <% else %> <p>Agent: <%= verdict_text(question) %></p> <% end %> </div>
  65. <%# app/views/questions/_question.html.erb %> <div id="<%= dom_target(question, :row) %>"> <div> Question

    <%= question.number %> <%= question.created_at.in_time_zone(Current.player.time_zone) .strftime("%l:%M %p %Z") %> </div> <p> <%= question.player == Current.player ? "You" : question.player.name %>: <%= question.body %> </p> <% if question.pending? %> <p>Agent: Thinking...</p> <% else %> <p>Agent: <%= verdict_text(question) %></p> <% end %> </div>
  66. class AnswerQuestionJob < ApplicationJob def perform(question) game = question.game game.resolve!(question)

    Turbo::StreamsChannel.broadcast_replace_to game, target: dom_target(game, :banner), partial: "games/banner", locals: { game: game } Turbo::StreamsChannel.broadcast_replace_to game, target: dom_target(question, :row), partial: "questions/question", locals: { question: question } end end <%# app/views/questions/_question.html.erb %> <div id="<%= dom_target(question, :row) %>"> <%= question.created_at.in_time_zone(Current.player.time_zone) .strftime("%l:%M %p %Z") %>
  67. class AnswerQuestionJob < ApplicationJob def perform(question) game = question.game game.resolve!(question)

    Turbo::StreamsChannel.broadcast_replace_to game, target: dom_target(game, :banner), partial: "games/banner", locals: { game: game } Turbo::StreamsChannel.broadcast_replace_to game, target: dom_target(question, :row), partial: "questions/question", locals: { question: question } end end <%# app/views/questions/_question.html.erb %> <div id="<%= dom_target(question, :row) %>"> <%= question.created_at.in_time_zone(Current.player.time_zone) Current.player.time_zone .strftime("%l:%M %p %Z") %>
  68. <%# app/views/questions/_question.html.erb %> <div id="<%= dom_target(question, :row) %>"> Question <%=

    question.number %> <%= question.created_at.utc.strftime("%H:%M %Z") %> <%= question.player.name %>: <%= question.body %> </div>
  69. <%# app/views/questions/_question.html.erb %> <div id="<%= dom_target(question, :row) %>"> Question <%=

    question.number %> <time datetime="<%= question.created_at.utc.iso8601 %>" data-controller="local-time"> <%= question.created_at.utc.strftime("%H:%M %Z") %> </time> <span data-controller="meta-text" data-meta-text-name-value="player-key" data-meta-text-match-value="<%= question.player.key %>" data-meta-text-content-value="You"> <%= question.player.name %> </span>: <%= question.body %> </div>
  70. // app/javascript/controllers/local_time_controller.js import { Controller } from "@hotwired/stimulus" export default

    class extends Controller { connect() { this.element.textContent = new Date(this.element.dateTime) .toLocaleTimeString([], { hour: "numeric", minute: "2-digit", timeZoneName: "short" }) } }
  71. <%# app/views/layouts/application.html.erb %> <meta name="player-key" content="<%= Current.player&.key %>"> // app/javascript/controllers/meta_text_controller.js

    import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { name: String, match: String, content: String } connect() { if (this.matched) this.element.textContent = this.contentValue } get matched() { return Boolean(this.matchValue) && this.matchValue === this.fact } get fact() { return document.querySelector(`meta[name="${this.nameValue}"]`)?.content } }
  72. <%# app/views/layouts/application.html.erb %> <meta name="player-key" content="<%= Current.player&.key %>"> // app/javascript/controllers/meta_text_controller.js

    import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { name: String, match: String, content: String } connect() { if (this.matched) this.element.textContent = this.contentValue } get matched() { return Boolean(this.matchValue) && this.matchValue === this.fact } get fact() { return document.querySelector(`meta[name="${this.nameValue}"]`)?.content } }
  73. <%# app/views/layouts/application.html.erb %> <meta name="player-key" content="<%= Current.player&.key %>"> // app/javascript/controllers/meta_text_controller.js

    import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { name: String, match: String, content: String } connect() { if (this.matched) this.element.textContent = this.contentValue } get matched() { return Boolean(this.matchValue) && this.matchValue === this.fact } get fact() { return document.querySelector(`meta[name="${this.nameValue}"]`)?.content } }
  74. Master Hotwire The Rails and Hotwire Codex JavaScript for Rails

    Developers The Hotwire Club Better Stimulus Learn Hotwire with Rails, iOS & Android