Ollie H-M . @olliedoodleday
Custom Form Handling With Turbo
Turbo will be a default part of Rails from Rails 7, replacing Turbolinks and rails-ujs. This post is a result of time I spent digging into Turbo, in particular its implications for forms that don't seem to fit what Turbo is designed for: that is, forms that don't necessarily (or only) trigger a redirect or DOM changes. I don't have a definitive answer for what we should do in these cases, but I'll explain some options that might be useful if or when Turbo's constraints feel a bit awkward.
Introducing Turbo...
Most of this post is about Turbo Drive, one of the four techniques that together constitute Turbo.
Turbo Drive is the bit that intercepts link clicks and form submissions to avoid full page reloads. It’s the new incarnation of Turbolinks, which has been a default part of Rails apps for a long time. Turbolinks only intercepted link clicks, not form submissions - but now, if you have Turbo installed, a form without any data attributes will automatically be handled and ultimately submitted by Turbo’s javascript. This means form submissions are by default ajax requests, which don’t result in a full page load when the browser gets a response.
So what does happen with the response after Turbo submits a form?
a) If the response is a redirect, Turbo will follow that redirect, navigating to the new page (without a full page load) as if the user had clicked a link. This is equivalent to the redirect support in Turbolinks-Rails when a form is submitted as an ajax request - in other words, we did have a way pre-Turbo to submit a form and redirect without a full page load.
b) If the response is
html
and the status is 4XX or 5XX, Turbo will render that
html
(without changing the URL). Turbolinks-Rails didn’t do this. Previously, if a
POST
request returned some
html
, nothing would happen without custom javascript to swap that
html
into the page or simulate a Turbolinks visit.
c) If the response is a 'Turbo Stream' response, Turbo will process it... A what? Turbo Streams are a new kind of response. Their content-type header is
text/vnd.turbo-stream.html
and they contain one or more Turbo Stream elements, which are custom
html
elements. Turbo automatically appends these elements to the DOM and whenever such an element is added, it triggers DOM changes (such as appending or replacing or removing
html
) as specified by the markup in the Turbo Stream element.
Those three alternatives are the only things Turbo is designed to do after a form is submitted:
Doing what Turbo isn't designed for...
These constraints are deliberate and there's no reason to debate them. But it is important to understand them and what they mean in practice. If we want to do something Turbo isn’t really designed for, what should we do? What can we do?
I was learning about Turbo soon after implementing a checkout flow in Cookpad using stripe js. It works by creating a Payment Method in Stripe, then submitting the Payment Method’s
id
in a form to our server. If all goes well processing the purchase, the user is redirected to a success page. But the purchase might fail because the user needs to authorise the payment with their bank. In that scenario, our server returns the data needed to call stripe's confirmCardPayment function. And that function launches the authorisation flow for the user’s bank.1
Calling javascript functions using data returned by the server doesn’t feel like one of the Three Things Turbo is designed to do after submitting a form. So as I read about Turbo, I kept asking myself this: what if we need (or want) to do something else? Or, being a bit more specific:
"With Turbo set up, (how) can we submit a form then handle the response - in particular an error response - in a custom way, without only redirecting or inserting and/or removing some html?"
Option 1...
One option is to use Turbo up to a point, then, at that point, take over from it. Let Turbo submit the request, let Turbo handle a redirect, but prevent Turbo handling the response if, instead of rendering
html
or appending Turbo Stream elements, we want to do “other stuff” like call some javascript functions.
This is doable by listening for the
turbo:before-fetch-response
event, emitted after the request has been made but before the response has been used.
We can put this stimulus action on a form:
 <form data-action="turbo:before-fetch-response@document->prevent-default#preventDefault">
   ...
 </form>
Then define
preventDefault
in a
prevent-default
stimulus controller:
 export default class extends Controller {
   // Let Turbo make the request.
   // Check the response. If it's unsuccessful, stop Turbo attempting to handle the response.
   async preventDefault(event) {
     // The response is available here and we can block Turbo's default behaviour.
     if (!event.detail.fetchResponse.succeeded) {
       event.preventDefault()
       const json = await event.detail.fetchResponse.response.clone().json()
       console.log("Do stuff with the json...", json)
     }
   }
 }
Now, if the server responds with an error, we can do whatever we want. See how the response doesn't even have to be
html
.
But there's a problem 👈 UPDATE: The problem I describe below no longer exists. This update means the
turbo:before-fetch-response
event is fired on the form itself, not the
document
. Our stimulus action can be
turbo:before-fetch-response->prevent-default#preventDefault
instead of
turbo:before-fetch-response@document->prevent-default#preventDefault
.
Because the event target is was
document
, I couldn’t find a nice way to be sure it corresponds to the correct form on the page. I could have checked the URL the request was sent to, or put a DOM identifier in the response, but neither is ideal. Now that the target is the element that triggered the request, we can listen for the event on the specific form we want to handle. That gives us a convenient way to let Turbo make the request then optionally 'take over' when the response is ready.
(Also note that if the response content-type is
text/vnd.turbo-stream.html
, or the response status is 4XX or 5XX, we can take over from Turbo without needing to call
event.preventDefault()
. Turbo's default behaviour won't get in the way. Turbo only raises a "Form responses must redirect to another location" error if the response status is 200 and the content-type is something other than
text/vnd.turbo-stream.html
. And if the response body is JSON, Turbo won't render it on the page or do anything else with it.)
Option 2a...
Another option is to trigger the 'other stuff' (the stuff that isn’t inserting and/or removing
html
) by inserting some
html
.
For example, if we want to trigger stripe’s card authorisation flow, we can return a Turbo Stream element that appends a block of
html
that attaches a stimulus controller that triggers the card authorisation flow.
The Turbo Stream element could be rendered like this:
 <%= turbo_stream.update "stripe-authentication-container" do %>
   <%= render "shared/payment/authentication",
     client_secret: error_payload[:data][:client_secret],
     payment_method_id: error_payload[:data][:payment_method_id] %>
 <% end %>
When it's added to the DOM, it will update the contents of the
stripe-authentication-container
with an authentication partial.
The authentication partial could look like this:
 <div
   data-controller="stripe-authentication"
   data-stripe-authentication-public-key-value="<%= Rails.configuration.x.stripe.public_key %>"
   data-stripe-authentication-client-secret-value="<%= client_secret %>"
   data-stripe-authentication-payment-method-id-value="<%= payment_method_id %>">
 </div>
And the stimulus controller's connect function could look like this:
 async connect() {
   this.stripe = await loadStripe(this.publicKeyValue)
   const result = await this.stripe.confirmCardPayment(this.clientSecretValue, {
     payment_method: this.paymentMethodIdValue,
   })
   // ...handle the result by showing errors or sending another request to fulfil the purchase
 }
I think using a Turbo Stream to insert
html
as a way to do other things - things that could be done without inserting
html
at all - is in line with what the Turbo docs advocate here:
"Turbo Streams consciously restricts you to seven actions: append, prepend, (insert) before, (insert) after, replace, update, and remove. If you want to trigger additional behavior when these actions are carried out, you should attach behavior using Stimulus controllers."
Option 2b...
In the above example, the 'other behaviour' is triggered when a stimulus controller connects, which happens when an element is added to the DOM. In that sense, the additional behaviour is triggered by the DOM change.
But we could also use a Turbo Stream to trigger behaviour in a more roundabout way: the Turbo Stream could cause a stimulus controller (A) to connect, which could emit an event, which we could listen for in some other stimulus controller (B). Stimulus controller B would then perform the action not because it has just connected, making the resulting behaviour a bit more removed from the thing the Turbo Stream is designed for: making a DOM change.
We could render a Turbo Stream like this:
 <%= turb_stream.append "form" do %>
   <div data-controller="pass-error" data-pass-error-payload-value="<%= @object.errors.to_json %>"></div>
 <% end %>
The 'pass-error' stimulus controller could connect like this:
 connect() {
   this.element.dispatchEvent(
     new CustomEvent("error", { bubbles: true, detail: { payload: this.payloadValue }})
   )
   this.element.remove()
 }
And we could listen for the custom error event in the same way we can listen for
rails-ujs
ajax:error
events:
 <form data-action="error->error-handler#handleError">
   ...
 </form>
This effectively means using a Turbo Stream to simulate the standard way we (at Cookpad) currently handle a response payload. It feels a bit like hacking Turbo Streams to let us handle non-
html
responses, and isn't really in the spirit of Turbo… but it could be useful, especially if you want to switch to Turbo but continue acting on events similar to
ajax:error
.
Option 3...
Finally, even with Turbo installed (and Turbolinks removed), we don’t have to use it. We can disable Turbo on an individual form by adding a
data-turbo=false
attribute. This will result in a standard non-ajax form submission. Or we can add a
data-remote=true
attribute to the form. As long as we still have
rails-ujs
installed, the 'data-remote' attribute will stop Turbo handling the submission because
rails-ujs
will intercept it first.
This is definitely a way to have Turbo set up while handling a form response in ways Turbo isn't designed for. Submit the form with
rails-ujs
instead and act on the events it emits to do whatever needs to be done. Great.
Except that by default we then lose the option of responding to the submission with a redirect. Without Turbolinks-Rails installed, if you try to redirect in response to a
rails-ujs
form submission, nothing will happen...
What we need for
rails-ujs
to be viable in a non-Turbolinks setup is a way to redirect with Turbo when a non-Turbo ajax form is submitted.
And here it is, in the Turbo docs. A Turbo version of the Turbolinks-Rails redirect_to method. Drop this into your ApplicationController, and you can redirect with Turbo even when Turbo didn't submit the form.
Conclusions...
I don’t know how many others are or will be asking themselves the question I found myself asking, and I haven't found a definitive answer to that question anyway... But hopefully I have explained a few approaches that might help as we adapt to Rails without Turbolinks and without
rails-ujs
.
I'll finish with a bit of practical advice, because something that has become clear as I've tried out these approaches is a way to make the leap to Turbo a bit calmer and more gradual.
If your existing application submits
remote: true
forms, there's no need to rewrite them all straight away. Let
rails-ujs
continue intercepting the submissions. Let it continue emitting the convenient
ajax:error
and
ajax:success
hooks. Start by letting Turbo take over the other forms: Turbo will seamlessly3 turn them into ajax submissions and handle them without a full page load. Then consider each 'remote' form individually, either removing
remote: true
and refactoring to deliver the necessary behaviour with Turbo, or keeping
remote: true
, or using neither
rails-ujs
nor Turbo.
Thanks for reading, and feel free to get in touch with me @olliedoodleday. 👋
1 Similarly, after submitting the form, we call this complete function to finish processing an Apple or Google pay purchase. The argument we pass to
complete
depends on the server response.
3 Assuming the response is either a redirect or
html
with a 4XX or 5XX status.