Noel Rappin Writes Here

A Brief Hello to Hotwire and Turbo

Posted on December 24, 2020


This week, after some mysterious announcements about “New Magic”, Basecamp released Hotwire, their support tools for client-side development using HTML over the wire rather than JSON. These are the generic versions of the tools that power Hey. Hotwire consists of the already released StimulusJS, and Turbo, which is the successor to Turbolinks.

This is also the tool release that has been holding up Modern Front-End Development With Rails, on sale now! If you follow me on Twitter, you may recall me mentioning tools that I wasn’t sure if they would open source – those tools are Turbo Frames and Turbo Streams, and they did, in fact, open source them.

I’m very excited about these tools – I think it’s a huge step in the Rails “allow a small team to act like a big team” aesthetic. But that’s for a separate post.

That said, I was a little nervous as I moved to try them out, what with already being committed to incorporating them into my book.

Fortunately, I do like the tools, and I’ve so far been able to get them to work to do some cool things.

In this post, I’m going to show how I used Turbo to add a couple of effects to the sample app used in the book.

In the book, the Stimulus code supports a schedule page for a list of concerts at a fictional and frankly ridiculous music festival. The details are not super important for our purposes right now.

As part of the process of doing a new run-through of the book, I recreated the demo app from scratch. I built a stock Rails 6.1 app, meaning Webpacker was installed. I threw in a few gems (like Simple Form), and then set up the data model with some scaffolding, and brought in the two special pages that are discussed in the book.

So, I started this process with a basic Rails 6.1 CRUD app, with one special view: a schedule display that shows all the concerts and has some navigation bits. The only JavaScript thing I added was Tailwind support.

Then I tried to think of some features I could build that would take advantage of the new Turbo library and see how far I could go without writing any JavaScript.

The general point of Hotwire is that the server should communicate with the client by sending rendered HTML rather than JSON, and that the server should generally be the source of truth and business logic.

The whole point of the Turbo library is to make it extremely easy to call the server, receive HTML, and insert that HTML into your DOM. This was the classic, original Ajax behavior, but it’s now been augmented with much better tooling.

Okay. The first thing I thought would be cool was inline-editing. Given a concert display on the schedule, click an edit button to replace that display inline with the edit form, then on updating the form, update the page with the newly edited display, without reloading the rest of the page.

Here’s what the video looks like:

First Test

And here’s how I did it:

(Sidebar: I wrote this a little fast, I don’t think I left out any steps, but it’s possible my git history got scrambled. I may have missed a minor config step…)

Before I started this feature, I made sure that the individual concert display was available in its own Rails partial view. In general, the Turbo stuff is going to go far easier for you if you have aggressively created small partials.

I also pre-styled the concert edit form to have roughly the same layout as the display page.

With my app more or less where I wanted it I installed Turbo:

  • In my Gemfile I replaced turbolinks with turbo-rails, and reran bundle install.
  • I ran yarn add @hotwired/turbo-rails and also removed turbolinks and @rails/ujs. (The guidance right now is that Rails UJS is deprecated, but it’s not 100% clear how some of the Rails UJS behavior is being covered.)
  • In the Webpacker application.js file, I deleted the Turbolinks and Rails UJS lines, and replaced them with import { Turbo, cable } from "@hotwired/turbo-rails"

With Turbo installed, I then made the following changes to support inline editing:

I wrapped the entire partial for a concert’s display line in a Turbolinks frame with the new turbo_frame_tag helper, the exact code is:

<%= turbo_frame_tag(dom_id(concert)) do %>`,
  THE HTML FOR THE DISPLAY GOES HERE
<% end %>

I also added the edit button to that HTML, which was a perfectly normal, nothing-to-see-here Rails link:

<%= link_to(edit_concert_path(concert),
      class: "text-lg font-bold border p-2") do %>
  Edit
<% end %>

I also wrapped the entire concert edit form with the same helper: <%= turbo_frame_tag(dom_id(concert)) do %>, and made no other changes.

In the ConcertsController, which was a standard Rails scaffold-generated CRUD controller, I changed the response on a successful update from redirect_to(@concert) to render(partial: "concerts/schedule_concert", locals: {concert: @concert}) – just rendering the partial for single concert display line.

I think that was it, looking at my git history. At this point, the inline edit functionality works. With just those changes.

Let’s talk about why.

A Hotwire Turbo Frame makes the claim that, by default, any link or form submission inside the frame will have a response that contains the same turbo frame – <turbo-frame id="same-id">. Turbo will extract those contents and use them to replace the existing contents of the turbo frame. Any part of the response that is not inside that turbo-frame element is ignored.

So. Clicking on the edit button returns a regular old Rails get request for the edit form, which has a header and whatnot, but also contains the form itself, which I wrapped in a Turbo Frame earlier. When the response comes back, the client-side part of Turbo captures it, extracts the Turbo Frame part and replaces the display with the form.

Similarly, after we click on the update button, we send off a perfectly normal Rails update request, which, since we changed one line in the controller, returns the HTML to display one concert, wrapped in a Turbo Frame, and once again, client-side Turbo captures and extracts it. (Technically, I could have re-rendered the entire page, client-side Turbo would have captured the correct segment and ignored the rest. It’d work, but it’d be worse from a performance perspective).

And that’s it. I’ve written no JavaScript and added virtually no incidental complexity to the code, but I’ve got this cool inline editor almost for free.

What else can I do without pushing JavaScript?

Each of these concert display lines has a “Make Favorite” button, which puts the concert in a list of favorites at the top of the screen. Right now it reloads the entire page to do so, and I’d much rather it just redraw the favorites DOM frame, even though the button link is not actually in the frame.

You can also do this with out of the box Turbo Frames, though it was a tiny hair more complicated. Here’s the video:

Second Test

The code steps:

I extracted the favorites part of the page to its own partial and wrapped it in a turbo frame.

<%= turbo_frame_tag("favorite-concerts") do %>
  <section class="my-4">
    <div class="text-3xl font-bold">Favorite Concerts</div>
    <% if current_user.favorites.empty? %>
      <div class="text-xl font-bold">No favorite concerts yet</div>
    <% else %>
      <% current_user.concerts.each do |concert| %>
        <%= render "concerts/schedule_concert",   concert: concert, favorite: true %>
      <% end %>
    <% end %>
  </section>
<% end %>

Note that this partial calls the same schedule_concert partial to display a concert that the regular display does. I’ve added a local variable here to flag on whether the display is in the favorite section or not: since we’re displaying the same concert twice for favorites, I need to give them different DOM IDs.

So there’s a very minor change in the schedule_concert partial to put an if statement on the dom_id (and a minor change to add favorite: false to the existing calls to the partial).

And then a larger change to the Make Favorite and Remove Favorite buttons:

<% if current_user&.favorite(concert) %>
  <%= form_with(
    url: favorite_path(id: current_user.favorite(concert)),
    method: :delete,
    data: {"turbo-frame": "favorite-concerts"}
  ) do |form| %>
    <%= form.button("Remove Favorite", class: "inline text-lg font-bold border p-2 bg-white") %>
  <% end %>
<% else %>
  <%= form_with(
    url: favorites_path(concert_id: concert.id),
    method: :post,
    data: {"turbo-frame": "favorite-concerts"}
  ) do |form| %>
    <%= form.button("Make Favorite", class: "inline text-lg font-bold border p-2 bg-white") %>
  <% end %>
<% end %>

Okay, these are mostly perfectly normal Rails things, but there are a couple of wierdsies:

  • These buttons are now empty Rails forms via form_with rather than being buttons with link_to and a post method. This seems to have something to do with how Rails and Turbo handle the link_to request (I think the form is given a header which makes it a turbo request by default and the link doesn’t, and this affects what Rails returns, but I haven’t fully investigated yet). Right now, when I did this with link_to I get empty responses, but form_for works.
  • We add the argument data: {"turbo-frame": "favorite-concerts"} to each form.

This option sets up a data-turbo-frame attribute, which is a special attribute used by Turbo.

Turbo uses for this attribute to mean “when you get the response to this request, act as though the response is for this other Turbo Frame mentioned in the data-turbo-frame and not the one the link or form is in”.

So, even though the display is in Turbo Frame concert_45 or whatever, Turbo will extract the contents for favorite-concerts and replace the favorite-concerts Turbo Frame.

The special attribute data-turbo-frame="_top" replaces the whole page.

The FavoritesController needs a little bit of a change too, we need the controller actions to respond with the new contents of the favorite-concerts frame.

def create
  Favorite.create(user: current_user, concert_id: params[:concert_id])
  render(partial: "favorites/list")
end

def destroy
  @favorite = Favorite.find(params[:id])
  @favorite.destroy
  render(partial: "favorites/list")
end

Again, these are mostly normal Rails controller actions that both respond with the favorites/list partial, that renders the HTML for the list of favorites.

So the sequence here is:

  1. The “Make Favorite” button is pressed, calling FavoritesController#create, and telling Turbo that the frame being targeted is favorite-concerts.
  2. The FavoritesController#create responds with a list of concerts, wrapped in the favorite-concerts turbo frame tag.
  3. The result is used to replace the contents of the existing favorite-concerts turbo frame.

The sequence for removing a favorite is identical, except that the controller action is FavoritesController#destroy.

And this works, and is cool, and we still have written zero JavaScript and still added very little code complexity.

But it’s not perfect. The favorites list does update when a new favorite is added, rather than the entire page, but the actual concert display doesn’t, meaning the button text in the actual display doesn’t change from “Make Favorite” to “Remove Favorite”. Also, we don’t actually need to redraw the entire favorites list, we just need to add the new display line.

Turns out, we actually can do these things – have our add favorite request add to the list without redrawing the entire list and also redraw the original concert display line to get the remove behavior there.

The video evidence:

First Test

To do this, we use a different bit of Turbo called Turbo Streams. Turbo Streams lets you package several bits of HTML in one response along with instructions on what to do with the HTML once it’s received.

The general form of a Turbo Stream response looks like this:

<turbo-stream action="replace" target="favorite-concerts">
  <template>
    ALL THE HTML CONTENT
  </template>
</turbo-stream>

The target is the existing DOM ID on the page being changed, and the action is one of “replace”, “remove”, “update”, “append”, or “prepend”. (I think replace replaces the entire DOM element, and update just updates the contents of the element.)

You can have as many Turbo Streams in your response as you want, and each one is applied sequentially.

Turbo Streams are applied automatically when you make a Rails request from inside a Turbo Frame. To make this work, Rails adds a turbo-stream header, which does two things. First, it allows you to switch in the controller on format.turbo_stream just like you do for html or json, and second, it tells Turbo to apply stream processing to the response. (I haven’t tried it with link_to yet, it’s really designed to work with ActionCable, which is a whole other story)

What we need, then, is for our actions in the FavoriteController to return a response with two turbo streams, one that appends the concert to the favorites list and one that replaces the existing concert display with a new one that has the correct button behavior.

Here’s what I did:

  • The concert display remains as-is
  • The favorites controller changes slightly:
def create
  @favorite = Favorite.create(user: current_user, concert_id: params[:concert_id])

  respond_to do |format|
    format.turbo_stream {  }
  end
end

def destroy
  @favorite = Favorite.find(params[:id])
  @favorite.destroy

  respond_to do |format|
    format.turbo_stream {  }
  end
end

In both of these cases, we’ve changed the output to format.turbo_stream { }, which allows us to use the normal Rails respond_to behavior, which is to use default processing if the associated block is empty. So we do the default behavior for turbo_stream requests. The default behavior is what it would be for other kinds of Rails formats, it looks for and renders the contents of a view file app/views/<controller>/<action>.<format>.erb.

Our create response is then in app/views/favorites/create.turbo_stream.erb, and it looks like this:

<%= turbo_stream.append(
  "favorite-concerts",
  partial: "concerts/schedule_concert",
  locals: {concert: @favorite.concert, favorite: true}) %>

<%= turbo_stream.replace(
  dom_id(@favorite.concert),
  partial: "concerts/schedule_concert",
  locals: {concert: @favorite.concert, favorite: false}) %>

Turbo-rails provides these helper methods that create a Turbo Stream with the given action and DOM ID, then take the same set of options as the regular Rails render, in this case partial and locals, then placing the result of the render inside the Turbo Stream.

In this case, we wind up with two Turbo Stream templates in our response, one of which appends a single concert to the favorite list, and the other of which replaces the existing concert display with a refreshed one.

The destroy template is almost the same except that the remove action doesn’t need a partial to render, just the DOM ID of the element to remove.

<%= turbo_stream.remove(dom_id(@favorite.concert, "fav")) %>

<%= turbo_stream.replace(
  dom_id(@favorite.concert),
  partial: "concerts/schedule_concert",
  locals: {concert: @favorite.concert, favorite: false}) %>

And again, that’s it. This is the code that produced the third video.

So far, I’ve written no lines of JavaScript, and almost no lines of Ruby, most of what I’ve done has been to rearrange the output ERB and return different combinations of HTML in response to regular Rails CRUD actions.

Everybody has their own different definition of complexity, but it feels to me like I’ve added very little incidental complexity to this app in order to get all this client-side functionality. (I think it took me longer to get the styling on the form right than it took to get any of the functionality right).

As I said, I’m very excited to try this out on my own projects. I’ll talk about why in a future newsletter. In the mean time, buy the book and you’ll get more depth on Turbo and Stimulus as I write it.

Also, purely on Newsletter content, this is the kind of thing that will eventually be a paid-only post, but not yet. Don’t worry, you’ll have plenty of warning before the paid-only posts start in earnest.

Thanks, and I hope this all made sense, please comment here if you have any questions or thoughts.



Comments

comments powered by Disqus



Copyright 2024 Noel Rappin

All opinions and thoughts expressed or shared in this article or post are my own and are independent of and should not be attributed to my current employer, Chime Financial, Inc., or its subsidiaries.