This article was originally published on Rails Designer


The Hotwire trifecta allows you to write modern (Rails) web apps, without the need to write (a lot of) cumbersome JavaScript with Turbo's Drive, Frames, Streams or Morph. But with all these options, which one to use and when?

Hotwire, like Rails, needs a mental shift from you as the developer. You can try to use Stimulus on a per-component basis, but you get way more bang for your buck if you write general-purpose controllers instead. The same goes for the various Turbo options.

You get the most value out of Hotwire if you follow progressive enhancement, a web design strategy popularized in 2008 by Aaron Gustafson that ensures basic functionality for all users while layering advanced features for modern browsers. While this strategy is less crucial today given widespread modern browser support for CSS and JavaScript features, it remains valuable in helping you choose the right tool for the job.

I reach for each option in the following order:

  1. Turbo Drive
  2. Turbo Frame
  3. Turbo Stream (including Broadcasts)
  4. Turbo Morph

This might seem obvious, having it laid out like this. But it helps to not always reach for Turbo Streams or totally forget the various options Turbo Drives has.

I included a basic Rails app with a few commits for every step outlined here. I'd suggest you check it out to understand each point made better. The first commit is the starting point and uses the default Rails redirect_to (to root_path in this example). Use that to code along with this article. ✌️ The second commit is the end result of this article.

Turbo Drive

You will notice all works fine! The button's label gets updated and the count in the h1 as well. Nothing too special of course, this here is just a general page refresh (quicker though because of Turbo Drive so only the body is injected!).

But what you notice (through the forced scrolling I added), that the page “scrolls” back to the top. Ugh! Luckily for us, there is a little utility you can use: . This will preserve the scroll during page refreshes. Cool!

In app/views/layouts/application.html.erb add the following:

<%%= content_for(:title) || "Turbo When" %>
    
    
    
+   
    <%%= csrf_meta_tags %>
    <%%= csp_meta_tag %>

This is all pretty cool, but it only works for scroll on the body. Let's force scrolling to the main-element:

- 
+ 
- 
+

And now the scroll state isn't persisted anymore.

Turbo Frames

This is the moment Turbo Frames come in handy. Let's wrap the likes-partial in a turbo-frame element. And see what happens.

- 
+ 
  <%% if user.likes.exists?(post: post) %>
    <%%= button_to post_like_path(post, user.likes.find_by(post: post)), method: :delete do %>
      Unlike
    <%% end %>
  <%% else %>
    <%%= button_to post_likes_path(post), method: :post do %>
      Like
    <%% end %>
  <%% end %>
- 
+

Awesome, the Likes button changes based on the if/else statement and the scroll position is still maintained!

But wait… the count in the

element doesn't change!

Turbo Stream (including Broadcasts)

Now you reach for Turbo Streams! These are for smaller, (multiple) UI updates in different parts of the page.

Update the show action to extract the title into its own partial. While this is a fair bit of overhead for this simple example, it shows the conventional way to do it.

-   
-     <%%= @post.title %>
-     (<%%= pluralize(@post.likes.count, "like") %>)
-   
+   <%%= render partial: "title", locals: { post: @post } %>

    
      <%%= @post.content %>
    

    <%%= render partial: "likes/like", locals: { user: Current.user, post: @post } %>

Then the partial:

+ 
+  <%%= post.title %>
+  (<%%= pluralize(post.likes.count, "like") %>)
+

Then the Turbo Stream actions. The app/views/likes/create.turbo_stream.erb:

+ <%%= turbo_stream.replace "likes" do %>
+  <%%= render partial: "likes/like", locals: { user: Current.user, post: @post } %>
+ <%% end %>

+ <%%= turbo_stream.replace "title" do %>
+  <%%= render partial: "posts/title", locals: { post: @post } %>
+ <%% end %>

And app/views/likes/destroy.turbo_stream.erb:

+ <%%= turbo_stream.replace "likes" do %>
+  <%%= render partial: "likes/like", locals: { user: Current.user, post: @post } %>
+ <%% end %>

+ <%%= turbo_stream.replace "title" do %>
+  <%%= render partial: "posts/title", locals: { post: @post } %>
+ <%% end %>

Yes, they are identical! You could probably change the logic and use the update action to catch both, but I prefer my actions to be RESTful and down the road requirements between what needs to happen between the actions might change. So I'd rather have some duplication then making my code to smart.

There is one more step to take and that is to remove the redirect_to in both controller actions:

class LikesController < ApplicationController
  before_action :set_post, only: %w[create destroy]

  def create
    Current.user.likes.create(post: @post)
-
-   redirect_to root_path
  end

  def destroy
    Current.user.likes.find_by(post: @post).destroy
-
-   redirect_to root_path
  end

  private

  def set_post
    @post = Post.find(params[:post_id])
  end
end

And with that we have all the same functionality back.

Turbo Morph

So how about Turbo Morph? While my experience with it is limited, it is limited because I cannot get morphing to work easily or without unexpected side-effects. Even with small example app, it is not working when following the docs. See this commit for this set up. I like the premise of it, but currently it feels like a bit too much “magic” (a.k.a. I don't know what's happening).

Feel free to reach out if you have any insights or real world examples. I'm happy to hear from you, and possibly post back here.

Résumé

This, somewhat contrived example, should make it clear on when feature to use. In short:

  • default to Turbo Drive, with any of its extra options;
  • changes on the same element: Turbo Frames
  • changes needed elsewhere on the page (one or many elements): Turbo Streams
  • changes needed outside of the current request cycle (i.e. not via a controller action): Turbo Stream Broadcasts

Have an example you are unsure about? Do reach out, I am happy to help you make the right decision.