Hotwire: Supercharged Rails forms with Turbo

Alexis Chávez
15 min readNov 1, 2023

--

Introduction

Rails 7 featured the new addition of Hotwire which is a bundle of frontend tools that allow us to build modern and fast web applications without writing any JavaScript(most of the times). The idea of Hotwire is to send HTML “over the wire” instead of JSON, this means that the server will deliver directly the HTML that should be rendered, reducing the overhead.

This article will work as an introduction for Turbo, which is the heart of Hotwire.

What is turbo?

Turbo is a simplified collection of techniques that enhance the speed of page transitions and form submissions. It allows us to break down complex web pages into smaller components and stream partial page updates over WebSocket. All without writing any JavaScript at all!

These techniques are:

Turbo Drive

Turbo Drive offers the speed benefits of traditional single-page applications (SPAs) without requiring developers to build their entire application around the SPA paradigm. By intercepting link clicks and form submissions, Turbo Drive leverages a persistent-process model to render HTML responses and seamlessly update page content, providing a simplified and efficient approach to achieving high-speed user experiences.

Turbo Frames

Turbo Frames allows you to organize independent segments of a web page into frame elements, enabling scoped navigation and lazy loading. Each frame operates within its own scope, it can be appended, prepended, replaced or removed without reloading the entire page.

Turbo Streams

Turbo Streams provides the ability to update any part of a web page in response to updates received through WebSocket connections, SSE, or other transports. This enables dynamic changes to the page, such as automatically updating an inbox when a new email arrives, and enhances the interactive and responsive nature of the application.

Building forms with Turbo

In this post our main focus will be Turbo Frames and we will be building a To-Do list that will behave as a SPA, making all our actions happen in the index.

Anatomy of a Turbo Frame

We can create a Frame by wrapping content or a segment of the page in a <turbo-frame> element.

<turbo-frame id="list">
<div>
...
</div>
</turbo-frame>

Frames require a unique ID, which is used to match the content being replaced when requesting new pages from the server.

This custom element intercepts interactions within the frame, making it a distinct component of your webpage. By default, links and forms within the frame aim to update only the content of that specific frame, whether the server sends a completely new HTML document or just a page fragment.

We can also render a Turbo Frame with the built-in helper from Turbo Rails:

<%= turbo_frame_tag "list" do %>
<div>
...
</div>
<% end %>

Creating a new to-do

Our to-do list currently looks like this:

/to_dos/index

Right now when we click the “Add+” button we get redirected to a “new” to-do form.

/to_dos/new

We actually want the form to be appended in our index view instead of being redirected.

Let’s wrap our “Add +” button within a Turbo Frame and call it new_to_do_frame.

# app/views/to_dos/index.html.erb

<div class="container"
<%= turbo_frame_tag "new_to_do_frame" do %>
<div class="header">
<h3 class="text-5xl">to-dos list</h3>
<%= link_to new_to_do_path "Add +" %>
</div>
<% end %>

<ul role="list">
<%= render @to_dos %>
</ul>
</div>

We should be able to see the HTML our Turbo Frame generated in the DOM now:

Let’s click the “Add +” button.

Let’s break down what’s happening in this scenario.

Turbo is expecting a frame with the same id on the target page to replace the existing content with. However, if there is no frame with a matching id, Turbo will remove the content from the source page Turbo Frame and log an error message. In this case, our target page is app/views/to_dos/new .

Adding a matching Turbo Frame:

# app/views/to_dos/new.html.erb

<%= turbo_frame_tag "new_to_do_frame" do %>
<%= render "form", to_do: @to_do %>
<% end %>

If we refresh our page and click the “Add +” button we will see the content of our source Turbo Frame in the index page being replaced(filled) by the content of our target Turbo Frame in the new page.

It looks good! And it’s all happening in real time! No page reloading.

What happens if we try to submit a new to-do?

If you click the “Create” button a new to-do will be created but it won’t show in the list until we refresh the page. We want our new to-do to be appended to our list in real time as well right?

To do this, we need to learn about Turbo Streams. But before doing that, we’ll briefly checkout the data-turbo-frame data attribute.

You might have noticed that our table’s header is being replaced by the form when we click “Add +”. This is because our source Turbo Frame is wrapping the table header and everything else within, which is then replaced by the content in our target Turbo Frame.

There will be times when you need to update the content of a frame using a link that isn’t directly nested in your target frame. To handle this situations, we can make use of data-turbo-frame data attribute.

Let’s try it out by rendering our new form in the table body instead of replacing the header like we intended with our first sketch. We’ll need to make a small refactor in order to implement our new feature:

# app/views/to_dos/index.html.erb

<div class="container"
<%= turbo_frame_tag "new_to_do_frame" do %> # we will no longer need this
<div class="header">
<h3 class="text-5xl">to-dos list</h3>
<%= link_to "Add +"
new_to_do_path,
data: { turbo_frame: "data_attribute_frame" } %>

</div>
<%= turbo_frame_tag "data_attribute_frame" %>
<% end %>

<ul role="list">
<%= render @to_dos %>
</ul>
</div>

Renaming our Turbo Frame in our new to-do view:

# app/views/to_dos/new.html.erb

<%= turbo_frame_tag "data_attribute_frame" do %>
<%= render "form", to_do: @to_do %>
<% end %>

The result:

A link can target a Turbo Frame even if it’s wrapped within the frame using the data-turbo-frame data attribute. If the source and target pages have Turbo Frames with the same id as specified in the data-turbo-frame attribute, the target Turbo Frame will replace the source Turbo Frame.

Turbo Streams

In Rails 7, forms are submitted with the Turbo Stream format by default. You can see this in your Rails server logs when we create a new to-do:

Started POST "/to_dos" for ::1 at 2023-08-03 14:50:49 -0600
Processing by ToDosController#create as TURBO_STREAM

We can make use of Turbo Streams to have our create action to exclusively append the Turbo Frame containing the recently created to-do without affecting the rest of the page. Let’s update our to_dos_controller so it knows to support both HTML and Turbo Stream formats.

# app/controllers/to_dos_controller.rb

def create
@to_do = ToDo.new(to_do_params)
if @to_do.save
respond_to do |format|
format.html { redirect_to to_dos_path }
format.turbo_stream
end
else
render :new, status: 422
end
end

We also need to create the corresponding view for our Turbo Streams format:

# app/views/to_dos/create.turbo_stream.erb

<%= turbo_stream.prepend "to_dos", partial: "to_dos/to_do", locals: { to_do: @to_do } %>

So what’s happening here?

We’re telling Turbo to prepend to the element with id “to_dos” the to_dos/_to_do partial and its locals.

We just need to add a Turbo Frame with id “to_dos” wrapping our list of to-dos in the to_dos/index page:

# app/views/to_dos/index.html.erb

<div class="container"
<%= turbo_frame_tag "new_to_do_frame" do %>
<div class="header">
<h3 class="text-5xl">to-dos list</h3>
<%= link_to "Add +"
new_to_do_path,
data: { turbo_frame: "data_attribute_frame" } %>

</div>
<%= turbo_frame_tag "data_attribute_frame" %>
<% end %>

<ul role="list">
<%= turbo_frame_tag "to_dos" do %>
<%= render @to_dos %>
<% end %>
</ul>
</div>

Let’s submit our form one more time and inspect the Network tab in the dev tools. This is our response body:

<turbo-stream action="prepend" target="to_dos"><template><li class="flex items-center justify-between gap-x-6 py-5">
<div class="min-w-0">
<div class="flex items-start gap-x-3">
<h1 class="text-2xl font-extrabold leading-none tracking-tight text-gray-900">write blogpost</h1>
<p class="rounded-md whitespace-nowrap mt-0.5 px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset text-green-700 bg-green-50 ring-green-600/20">Complete</p>
</div>
</div>
<div class="flex flex-none items-center gap-x-4">
<form class="button_to" method="post" action="/to_dos/18"><input type="hidden" name="_method" value="delete" autocomplete="off" /><button type="submit">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 text-gray-600 hover:text-gray-400">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button><input type="hidden" name="authenticity_token" value="N-X93fx1gJtROeZFUg05iS4BOenmDMDAU4XqxY72Wd5pCo13TmIbuQhxTd1_GMoMw4lz9otDhRv-x5PTyPGjyg" autocomplete="off" /></form>
<div class="relative flex-none">
<form class="button_to" method="post" action="/to_dos/18"><input type="hidden" name="_method" value="delete" autocomplete="off" /><button type="submit">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 text-gray-600 hover:text-red-400">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button><input type="hidden" name="authenticity_token" value="Vo0IR3SpnffMs9hhSithLAsyqVtDe1jgTy2H6Nc8yLgIYnjtxr4G1ZX7c_lnPpKp5rrjRC40HTvib_7-kTsyrA" autocomplete="off" /></form> </div>
</div>
</li></template></turbo-stream>

We can see that a Turbo Stream message is a fragment of HTML consisting of <turbo-stream> elements. Each element has an action and a target matching our create.turbo_stream.rb view as well as a <template> element wrapping its included HTML.

Our to-do creation should be working as expected now.

Here’s a list of methods that Turbo Streams responds to:

turbo_stream.remove

turbo_stream.append

turbo_stream.prepend

turbo_stream.before

turbo_stream.after

turbo_stream.update

turbo_stream.replace

We can refactor our Turbo Stream view using the dom_id helper:

<%= turbo_stream.prepend "to_dos", @to_do %>
<%= turbo_stream.update ToDo.new, "" %> # this will remove our new to-do frame after submission

The turbo_frame_tag helper automatically passes the given object to the dom_id helper to convert our to_do object into a unique id as well:

turbo_frame_tag "to_do#{@to_do.id}" # => "to_do_1"
turbo_frame_tag @to_do # => "to_do_1"

As well as our Turbo Frame tags:

# app/views/to_dos/index.html.erb

<div class="container"
<div class="header">
<h3 class="text-5xl">to-dos list</h3>
<%= link_to "Add +"
new_to_do_path,
data: { turbo_frame: dom_id(ToDo.new) } %>

</div>
<%= turbo_frame_tag ToDo.new %>

<ul role="list">
<%= turbo_frame_tag "to_dos" do %>
<%= render @to_dos %>
<% end %>
</ul>
</div>
# app/views/to_dos/new.html.erb

<%= turbo_frame_tag @to_do do %>
<%= render "form", to_do: @to_do %>
<% end %>

Editing a to-do

To edit a to-do we want to replace the list item that contains our to-do with the edition form when clicking on the “Edit”. In order to achieve this we’ll need to wrap each item in a unique Turbo Frame and we’ll need to do it in a dynamic way.

Let’s go ahead and wrap each of our to-dos in a Turbo Frame with a unique id.

# app/views/to_dos/_to_do.html.erb

<%= turbo_frame_tag to_do do %>
<div class="to_do">
<li>
<h1 class="text-2xl><%= to_do.title %></h1>
<div class="actions">
<%= button_to "Delete", to_do_path(to_do), method: :delete %>
<%= link_to "Edit", edit_to_do_path(to_do) %>
</div>
</li>
</div>
<% end %>

We need another Turbo Frame of the same id around our edit form:

# app/views/to_dos/edit.html.erb

<%= turbo_frame_tag @to_do do %>
<h1 class="title">Edit to-do</h1>

<%= render "to_dos/form", to_do: @to_do %>
<% end %>

Our edit functionality is now working as expected!

Deleting a to-do

If we click on the trashcan to delete a to-do, an error will be logged in the console and a content missing message will be rendered. This is a similar issue we had earlier where turbo is expecting to find a Turbo Frame with the same id on the target page but the destroy response has no matching <turbo-frame id="to-do-id">causing the frame to disappear and an error to be logged.

If we refresh the page we can see the to-do was actually removed, but how can we fix Turbo logging an error and having to refresh the page every time we want to remove a to-do?

We can make use of turbo stream in a very similar way we implement our create functionality. First, let’s add Turbo Stream format support to our destroy action:

# app/controllers/to_dos_controller.rb

def destroy
@to_do = ToDo.find(params[:id])
@to_do.destroy

respond_to do |format|
format.html { redirect_to to_dos_path }
format.turbo_stream
end
end

Just like with any other format, we need to create the corresponding view:

# app/views/to_dos/destroy.turbo_stream.erb

<%= turbo_stream.remove @to_do %>

If we click the trashcan one more time, Turbo will now know to remove the Turbo Frame of the specified to-do id leaving the rest of the page untouched.

Cancel button

We now have a fully functional to-dos application but it could still use a few improvements. The only way for a user to cancel the creation or edition of a to-do is to refresh the page or submit the form. We can add a ‘cancel’ button right next to the ‘submit’ button in our to-dos form:

# app/views/to_dos/_form.html.erb

<%= form_with(model: to_do, url: to_do.persisted? ? to_do_path(to_do) : to_dos_path, method: to_do.persisted? ? :patch : :post, class: "mt-20") do |form| %>
<% if form.object.errors.any? %>
<div id="error_explanation">
<h3 class="text-sm font-medium text-red-800"><%= pluralize(form.object.errors.count, "error") %> prohibited this item from being saved:</h3>
<div class="mt-2 text-sm text-red-700">
<% form.object.errors.full_messages.each do |message| %>
<p><%= message %></p>
<% end %>
</div>
</div>
<% end %>
<%= form.text_field :title %>
<%= link_to "Cancel", to_dos_path, class: "button-secondary" %>
<%= form.submit to_do.persisted? ? "Edit" : "Create", class: "button-primary" %>
<% end %>

Since our cancel button is a link within a Turbo Frame, Turbo will only replace the content of this frame instead of reloading the whole page. Go ahead and test it, try editing, creating, canceling or deleting multiple to-dos at once. You’ll notice that the state of the page is preserved. This is because Turbo Frames are independent pieces of the page that we can easily manipulate without writing any JavaScript.

The result:

Bonus: Using a modal to create a new to-do

Remember the last section where I was bragging about not using any JavaScript? While Turbo usually takes care of most of the interactivity that traditionally would have required JavaScript, there are still cases where a dash of custom code is required. This is where Stimulus comes in.

According to the Stimulus handbook:

Stimulus is a modest JavaScript framework, it works by connecting JavaScript objects to elements on the page using simple annotations. These JavaScript objects are called controllers and Stimulus continuously monitors the page waiting for HTML data-controller attributes to appear. For each attribute, Stimulus looks at the attribute’s value to find a corresponding controller class, creates a new instance of that class, and connects it to the element.

Right now when we open up a new or edit form, a Turbo Frame with the form gets appended inside our to-dos list. Instead, we want to make the creation of new items happen in a modal.

First, we’ll add a ‘modal’ Turbo Frame to the application’s layout, this is a good way to ensure there is always an empty modal on every page and we can target it from anywhere in the application:

# app/views/layouts/application.html.erb

<body>
<%= yield %>
<%= turbo_frame_tag "modal" %>
</div>
</body>

Now update the turbo_frame_tag id in new.html.erb :

# app/views/to_dos/new.html.erb

<%= turbo_frame_tag "modal" do %>
<div class="modal">
<div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>

<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 animate-fade-in-up">
<div class="mt-3 text-center sm:mt-5">
<div class="mt-2">
<h1 class="mb-4 mt-8 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-3xl">New to-do</h1>
<%= render "to_dos/form", to_do: @to_do %>
</div>
</div>
<div class="mt-5 sm:mt-6">
<%= button_to "Go back", "#", class: "inline-flex w-full justify-center rounded-md bg-gray-400 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-gray-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %>
</div>
</div>
</div>
</div>
</div>
</div>
<% end %>

(Note: I’m using tailwind css to style the modal)

Next, we need to modify the link_to data attribute to target our ‘modal’ Turbo Frame:

# app/views/to_dos/index.html.erb

<%= link_to "Add +"
new_to_do_path,
data: { turbo_frame: "modal" } %>

Try creating a new to_do, you should now see the form popping up in the form a modal. Our modal is almost complete now, you might notice the modal is refusing to close if we either submit the form or click the “Go back” button. We need to create a stimulus controller that handles closing our modal.

# app/javascript/controllers/modal_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["modal"]

hideModal() {
this.element.parentElement.removeAttribute("src")
this.modalTarget.remove()
}

submitEnd(e) {
if (e.detail.success) {
this.hideModal()
}
}
}

Finally we need to add the data-attributes to our modal so Stimulus can connect the controller to our modal.

<%= turbo_frame_tag "modal" do %>
<div class="modal" data-controller="modal" data-modal-target="modal" data-action="turbo:submit-end->modal#submitEnd">
...
<%= button_to "Go back", "#", data: { action: "modal#hideModal" } %>
<% end %>

Don’t forget to add the data-action to the “Go back” button to close the modal.

Ta-da 🎉. We now have a fully functional modal that works with Turbo.

Extra bonus: Show form errors inside the modal

We can do one more improvement to our modal, show errors.

The first thing we need to do is to create a shared error_messages layout which will serve as a reusable template for our form errors:

# app/views/shared/_error_messages.html.erb

<div class="rounded-md bg-red-50 p-4" id="errors">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Oops</h3>
<div class="mt-2 text-sm text-red-700">
<p><%= errors %></p>
</div>
</div>
</div>
</div>

Next step is to add a turbo_frame_tag right at the top of our modal:

<%= turbo_frame_tag "modal" do %>
<div class="modal" data-controller="modal" data-modal-target="modal" data-action="turbo:submit-end->modal#submitEnd">
<div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>

<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 animate-fade-in-up">
# right here
<%= turbo_frame_tag "errors" %>
...
<% end %>

The final step is adjusting the create action in our to-dos controller to respond to the Turbo Stream format when a to-do is not successfully saved:

def create
@to_do = ToDo.new(to_do_params)
if @to_do.save
respond_to do |format|
format.html { redirect_to to_dos_path }
format.turbo_stream
end
else
render turbo_stream: turbo_stream.update("errors", partial: "shared/error_messages", locals: { errors: @to_do.errors.full_messages }), status: :unprocessable_entity
end
end

With the above code we are letting Turbo know to update the ‘errors’ turbo_frame_tag with the content inside our _error_messages partial whenever a to-do fails to save. The final result looks like this:

We’re done! Our application should now be complete and work as expected. Let’s have a recap:

  • Turbo Frames serve well for simple functionalities, where individual page sections have their dedicated navigation and actions.
  • Turbo Streams, on the other hand, is better in scenarios demanding more advanced capabilities that Turbo Frames can’t cover, particularly when various segments of a page need to dynamically adapt to external data changes, like with AJAX calls.
  • Stimulus can be used in situations where you need to perform tasks that specifically require JavaScript . For example, showing or hiding elements dynamically.

If you want to take a look at the repo of the app I used to demo, click this link.

--

--