BRYAN HUNT

Turbo Form Submissions and Tokens

While I was working on converting some older forms in our app, I noticed I was able to double-click the submit button after changing one of the forms to submit with Turbo. I also noticed the button didn't disable with the "turbo-submits-with" message.

Currently, we listen for the native submit event, check if there's a token. If there is a token, we allow the submit to run. If there's not a token, we stop the event, generate the token, store a reference for that token, then resubmit the form which passes the first token check. It's a lot of logic to generate a simple token. I’m sure there were better ways to handle this, but it was working that way since we were on Turbolinks.

Unfortunately, Turbo's submits-with data attribute that disables the submit button, fires after the native submit event. So, while the token is generating, there's a window of time where the button isn't disabled and could be clicked twice, submitting the form twice. As long as everything is idempotent on the server's side, it doesn't make much of a difference, but why make extra, unnecessary, submissions?

To prevent those pesky extra submissions, we disabled the submit button ourselves when we check for the token. But all of this logic seems dirty. Shouldn't Turbo's "submits-with" data-attribute handle the disable state and provide a way to add dynamic values to the form after submission?

So I started digging into the "turbo:submit-start" event, which seems like the obvious place to start.

Unfortunately the, "submit-start" event doesn't disable the form until after it's looped through all of the form fields to prepare the form submission.

So I re-read the Turbo documentation and realized (far too late) that Turbo provides an event that JavaScript can hook into and apply asynchronously generated tokens seamlessly to the submission process.

Turbo emits the "turbo:before-fetch-request" event, which to me sounds more like a standard page "get" request, but it actually fires for form submissions as well.

This is the event I now hook into when generating asynchronous tokens during a form submission, and it drastically simplified our code.

By hooking into "turbo:before-fetch-request" I can prevent the form submission, access the form params through the event.detail.fetchOptions, append whatever tokens need to be inserted into the body object, and resume the submission with event.detail.resume(). No more maintaining token references, or having to disable submit buttons manually—Turbo can handle the rest.

<%# form snippet %>
<%= form_with ..., data: {
controller: "token",
action: "turbo:before-fetch-request->token#handleSubmit" } do |f| %>
...
<% end %>
// token controller
import { Controller } from "@hotwired/stimulus"
import { tokenGenerator } from "some-token-library"

export default class extends Controller {
async handleSubmit(event) {
event.preventDefault()
const { token, error } = await this.#generateToken()

if (error) {
this.#handleError(error)
} else {
const formBody = event.detail.fetchOptions.body
formBody.append("token", token)
event.detail.resume()
}
}
}

And it's mostly documented here, although at the time, there was no mention about the body attributes of the fetchOptions object, so I had to do a little inspecting and digging to find out how to append those form options that.

There was also no mention of the order of form submission events which might be helpful, so for future reference, here it is:

  1. native submit event
  2. turbo:before-fetch-request
  3. turbo:submit-start
  4. turbo:before-fetch-response
  5. turbo:submit-end

Try it yourself to confirm

document.addEventListener("submit", event => console.log("native submit", event))

document.addEventListener("turbo:before-fetch-request", event => console.log("turbo:before-fetch-request", event))

document.addEventListener("turbo:submit-start", event => console.log("turbo:submit-start", event))

document.addEventListener("turbo:before-fetch-response", event => console.log("turbo:before-fetch-response", event))

document.addEventListener("turbo:submit-end", event => console.log("turbo:submit-end", event))