<%= form_with model: @project, data: { controller: 'disable-submit' } do |f| %>
<%= f.text_field :name, class: 'rounded border p-2' %>
<%= f.submit 'Save', data: { disable_submit_target: 'button' }, class: 'rounded bg-blue-600 px-3 py-2 text-white' %>
<% end %>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["button"]
connect() {
this.element.addEventListener("turbo:submit-start", this.disable)
this.element.addEventListener("turbo:submit-end", this.enable)
}
disconnect() {
this.element.removeEventListener("turbo:submit-start", this.disable)
this.element.removeEventListener("turbo:submit-end", this.enable)
}
disable = () => {
this.buttonTarget.disabled = true
this.buttonTarget.dataset.previousText = this.buttonTarget.value
this.buttonTarget.value = "Saving…"
}
enable = () => {
this.buttonTarget.disabled = false
this.buttonTarget.value = this.buttonTarget.dataset.previousText || "Save"
}
}
Double-submits are easy to trigger on slow connections, especially with Turbo where the page doesn’t visibly reload. I add a Stimulus controller that listens to turbo:submit-start and turbo:submit-end events on the form. On submit start, disable the submit button and optionally show a spinner. On submit end, re-enable unless the server redirected away. This improves UX and prevents duplicate records without needing complicated server-side idempotency for every form. I still keep server-side uniqueness constraints, but this is a good “first line of defense”. Because Turbo emits consistent events, the same controller works for normal page forms and for forms inside frames or modals.