One of the big changes in Rails 7 was switching from Rails UJS to Turbo for adding more functionality to HTML forms. Turbo is one of the Hotwire libraries, developed to make it easier to build single-page web apps using server-side rendering. The idea is instead of your server returning JSON or similar from its APIs that your web app then converts into HTML, the server just directly returns the HTML that should be rendered.
This article is intended as an introduction to using Turbo with forms. The web server will be implemented using Rails 7, but the same ideas can be used with any server-side framework. If you are using Rails, make sure you have set up Turbo in your app and have the turbo-rails gem installed. We will be implementing an extremely basic forum app, so basic it won’t even have users, just discussions and posts.
A complete code example for this article can be found on GitHub.
Progressive enhancement is the idea that your website should be fully functional on all web browsers, including those with no JavaScript support (e.g. the user has disabled it over privacy concerns), and any feature used that is not supported by every browser should have a graceful fallback. While it is often impractical to expect a complex modern app to be fully functional with zero JavaScript, Turbo has been designed with this in mind, and thinking about your forms from a progressive enhancement perspective usually leads to forms implemented in the way Turbo intended.
A common result of completing a form is redirecting to a different page in your app. The example we’ll use here is redirecting to a discussion’s page after creating it. We’ll also include server-side validation to demonstrate how to display submit errors.
As explained previously, the first step is to figure out how the form would work with zero JavaScript. In this case it is fairly straightforward, on success a redirect response is returned to the discussion’s page, and on a validation error the whole page should be rendered with a message displayed to the user explaining the problem.
With Rails, a simple implementation of that could look like this:
‍The errors are wrapped in a div role="alert" in the hopes that screen readers will announce the errors, however on page load that behaviour is inconsistent. Fortunately, it will work when the form uses Turbo instead.
This form will work for everyone, but having to render the entire page to display validation error messages can potentially be slow and be a poor user experience. Let’s progressively enhance this form using Turbo. By returning a Turbo Stream element in the error response, the form can be replaced with one that includes validation errors.
In order to replace the form, it needs to be given an ID:
Next, a turbo-stream element needs to be returned with the new form:
Rails will render new.turbo_stream.html.erb when the request is performed by Turbo, and the controller does not need to be changed for this (assuming the turbo-rails gem has been installed). Note that Rails does not render any layouts for the turbo_stream format by default, so only the form had to be rendered to display the errors.
‍If your form redirects to an external URL that is not part of your app this solution does not work correctly. See this GitHub issue for details and a workaround.
The full code changes for this section can be seen here.
Let’s now move on to support creating posts in a discussion.
The first step, as always, is to figure out how this would work without JavaScript. At the bottom of the page for a discussion, there should be a form for creating a new post. Successfully submitting the form should redirect to the discussion. Validation errors should rerender the discussion’s page with error messages displayed in the form. This is straightforward to implement with Rails:
Inside app/views/discussions/show.html.erb a create post form is rendered:
Now, the form can be progressively enhanced. On success the new post should be appended to the list of posts, and the create post form should be replaced with a blank one. On a validation error the form should be replaced with one that includes validation errors. In order to do this, the element containing posts and the create post form will need IDs so that they can be referenced by Turbo Stream.
Now Turbo Stream can be used to append the new post and replace the form:
The full code changes for this section can be seen here.
People make mistakes, it would be nice if people could edit their posts.
Ideally editing posts should happen inline in the discussion, but that won’t be possible without JavaScript. Instead, a link to a separate edit page can be added, and the form on that page redirects back to the discussion on success.
(The form template is the same one used in the previous section.)
Turbo provides a convenient mechanism for handling use cases like this called Turbo Frames. All forms and links inside a turbo-frame element will by default only update the HTML within that element. Turbo checks the response of the form or anchor request to find a turbo-frame with the same ID as the one the form or anchor is within and replaces the content of the existing turbo-frame with the content of the new element.
The first step is to wrap each post and the edit form with a turbo-frame element:
‍Here a “turbo” suffix has been added to the ID of the turbo-frame. This is to avoid clashing with the ID of the form. In this example the form does not actually need an ID, but including it makes it easier to be compatible with the other examples.
Just this is actually enough to implement the desired behaviour! However, there is a catch - when the form redirects to the discussion page on success, the entire page is rendered and returned by the server, but Turbo only looks for the matching turbo-frame element, so most of that work gets thrown away. To make the update faster, it is possible to use Turbo Streams like in previous examples to update the post.
The full code changes for this section can be seen here.
With all this talk of progressive enhancement, I want to make sure my opinion on it is clear. I don’t actually believe it is possible for every modern web app to be implemented in a way where it is fully functional without JavaScript. There are often a lot of things that can only be done with JavaScript, and trying to force every piece of code to have a functional fallback in the absence of JavaScript is a lot of effort for minimal reward. That said, thinking in terms of progressive enhancement can help frame problems in a way where you’re more likely to try to use standard HTML elements and minimise the amount of custom JavaScript you do use. This leads to web apps that are more intuitive to use, less likely to break, and much more accessible. Thinking about web development in terms of HTML-first rather than JavaScript-first is the key idea that progressive enhancement encourages.
Turbo is a very powerful tool that allows your server to modify the DOM in ways that would usually be done with JavaScript. Not all interactivity can be achieved using this, but anything that would usually require an API call and DOM modification can usually be achieved easily with Turbo.