Turbo Frames Vs. Turbo Streams

Jul 20, 2022 - 11 min read
Turbo Frames Vs. Turbo Streams
Share

When I first found Hotwire, the documentation threw me a bit; I couldn't understand the difference between Turbo Frames and Turbo Streams.

They both seemed to do the same thing, but being a part of the Ruby community for the past 15+ years and knowing we're all fans of the Don't Repeat Yourself principle, I was 100% sure there was something I missed.

Here's a video version of this article if you prefer to watch instead.

Fast forward a few months of using Hotwire it became clear to me why Hotwire was built the way it was.

And in this article, I will explain when you should pick one over the other.

To give you a little bit of context, I'm using a simple controller where actions and templates are named "first", "second", and "third".

# app/controllers/site_controller.rb
class SiteController < ApplicationController
  def first
  end

  def second
  end

  def third
  end
end

That's why in the address bar you see "first". Because that is the starting page, which loads the HTML elements the user can interact with. In this case, it's the button.

# config/routes.rb
Rails.application.routes.draw do
  get '/first', to: "site#first", as: :first_page
  get '/second', to: "site#second", as: :second_page
  get '/third', to: "site#third", as: :third_page

  root "site#first"
end
First page

And then, when the user performs an action, like submitting a form, or clicking a button, a request goes out to the server, and then a response comes back to the browser, which updates the page.

Turbo Frames request

So in the case of this Turbo Frame, the server responds with a new frame, which replaces the contents of the existing frame displaying a message.

And the same goes for Turbo Streams.

A request gets sent to the server, the server sends back a response, and the browser updates the page.

Turbo Streams request

So at this point, you can probably see why I got confused.

It's because the differences are not obvious at first. You have to dig a little deeper to see why both Turbo Frames and Turbo Streams exist.

Let's look at Turbo Frames first.

Turbo Frames

Turbo Frames are great for taking an old-style full-HTML page and turning it into a modern, responsive-looking page in minutes.

All you have to do is wrap the slice of the page you want in a <turbo-frame> tag. That's it!

That's all there is to it.

Let's take this page as an example, which doesn't have any Turbo Frames.

First page

Whenever you click this button, it takes you to the second page, and you can see that the response is another full page with headers, body, and everything else.

As you would expect.

Click link

It's a completely new page. Even though because of Turbo Drive, it's a bit faster to render. But otherwise, there's nothing special going on.

What we want to achieve with Turbo Frames is we want the contents of the second page, to be loaded within the bottom half of the first page.

In other words, we want to re-fetch the contents of the second page whenever we click the button.

Alright, so let's add a Turbo Frame now.

I will wrap everything in a frame because the button needs to be inside the frame that we want to update. And since we want to update the contents of the div with the id of content, we've gotta wrap everything in a frame.

<!-- app/views/site/first.html.erb -->
<%= turbo_frame_tag "frame1" do %>
  <div class="w-full text-center my-8">
    <%= link_to "Load stuff!", second_page_path, class: "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 mr-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" %>
  </div>

  <div class="my-8 w-full mx-auto" id="content">
    <%= render "the_stuff" %>
  </div>
<% end %>

And we're going to do the same for the response.

The response needs a frame with the same identifier as the one that originated the request. In this case, it's going to be frame1.

<!-- app/views/site/second.html.erb -->
<%= turbo_frame_tag "frame1" do %>
  <%= render "the_stuff" %>
<% end %>

So after clicking the button, we can see that the response doesn't contain the entire page anymore.

Turbo Frame response

It only returned the contents of the template. This is great because there's a lot less content to send over the wire.

But there's a small problem with this setup.

Namely the fact that the link has been replaced with the contents of the second template.

That's because the response updated the entire frame, not just the part we wanted to update.

Fortunately, this is easy to fix; we can set the data-turbo-frame attribute on the link to explicitly tell Turbo which frame to target when the response comes back from the server.

<!-- app/views/site/first.html.erb -->
<div class="w-full text-center my-8">
  <%= link_to "Load stuff!", second_page_path, data: { turbo_frame: "frame1" }, class: "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 mr-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" %>
</div>

<!-- app/views/site/second.html.erb -->
<%= turbo_frame_tag "frame1" do %>
  <div class="my-8 w-full mx-auto" id="content">
    <%= render "the_stuff" %>
  </div>
<% end %>

So we'll wrap the second div in the frame tag, and we'll set the data-turbo-frame attribute to the frame's identifier. Namely frame1.

And if we retry this in the browser, the behavior is correct now.

Turbo Frame data attribute

The button is still on the page, and the second half of the screen is now populated with new content every time we click the button.

That's amazing. It feels like a Single-Page-App. And notice how easy it was to add the Turbo Frame. Just a few wrapping tags, and you're done.

That's a big benefit that Turbo Frames have over Turbo Streams.

Another benefit is you can lazy-load (or progressively load) page segments as they become visible. And you can also cache the frames independently of each other.

But there are three problems with Turbo Frames.

  1. The first problem is we can only update one frame at a time. This means if we wanted to click a button and update two or more sections of the page, it wouldn't work.
  2. The second problem is that we can only update the frame's content. We cannot delete, prepend, append, or anything other than just update.
  3. The third problem is that we cannot update the page programmatically by sending new content from the back-end.

Turbo Streams

And that's where Turbo Streams come into play. They don't have those limitations.

But, there is a small price to pay. You've gotta write more code to wire everything up.

So let's see how we could replace our Turbo Frame with Turbo Streams.

I will remove the frames from both the first page and the second.

<!-- app/views/site/first.html.erb -->
<div class="w-full text-center my-8">
  <%= link_to "Load stuff!", second_page_path, class: "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 mr-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" %>
</div>

<div class="my-8 w-full mx-auto" id="content">
  <%= render "the_stuff" %>
</div>
<!-- app/views/site/second.html.erb -->
<%= render "the_stuff" %>

So now we're back to our link taking us to a new page.

Removed frame

Cool.

As I said, there's more code we have to write to wire Turbo Streams up. Also, you gotta use non-GET requests like forms instead of links.

This limitation will probably be removed soon, but at the time of writing this, it's still there.

So I'll replace the link_to helper with button_to, which generates a form.

<!-- app/views/site/first.html.erb -->
<div class="w-full text-center my-8">
  <%= button_to "Load stuff!", third_page_path, class: "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 mr-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" %>
</div>

<div class="my-8 w-full mx-auto" id="content">
  <%= render "the_stuff" %>
</div>

And the form will point to the third action which needs to be a post route instead of a get route.

# config/routes.rb
Rails.application.routes.draw do
  get '/first', to: "site#first", as: :first_page
  get '/second', to: "site#second", as: :second_page
  post '/third', to: "site#third", as: :third_page

  root "site#first"
end

Lastly, we need to return a response. And the response uses these Turbo Stream messages to tell the browser what to do.

In this case, we want to update the contents of the content div.

<!-- app/views/site/first.html.erb -->
<%= turbo_stream.update "content" do %>
  <%= render "the_stuff" %>
<% end %>

So if I try this in the browser, by clicking the button, you'll see the response looks a bit different.

It's got a different tag now; it's <turbo-stream> instead of <turbo-frame>, and it's got an action, and a target.

The action is update, and the target is our div ID.

But we could have more than one action, as opposed to Turbo Frames where you only have the update option.

We could add some content before the div.

<!-- app/views/site/first.html.erb -->
<%= turbo_stream.update "content" do %>
  <%= render "the_stuff" %>
<% end %>

<%= turbo_stream.before "content" do %>
  ADDED BEFORE
<% end %>
Add before

You could append, prepend, replace, remove, add content after the element, and also, you could target multiple elements with one message.

So there are a lot more options available with Turbo Streams than there are with Turbo Frames.

But that's not all.

Live streams

One of the significant differences is you can update the page via WebSockets.

This means you can open up a WebSocket connection in your browser, and then you can send data through that connection from anywhere in your application.

WebSockets

It could be a controller, a model, or even the Rails console.

So let's set it up and try it.

I'll add a new POST route named fourth which we'll trigger using our button, and inside the fourth action, we can use the Turbo::StreamsChannel module to send Turbo Stream messages over the WebSocket connection.

# config/routes.rb
Rails.application.routes.draw do
  get '/first', to: "site#first", as: :first_page
  get '/second', to: "site#second", as: :second_page
  post '/third', to: "site#third", as: :third_page
  post '/fourth', to: "site#fourth", as: :fourth_page

  root "site#first"
end
# app/controllers/site_controller.rb
def fourth
  Turbo::StreamsChannel.broadcast_update_to("mystr", target: "content", partial: "site/the_stuff")
end

So let's just broadcast updates, as we did before. We'll update the content div through the mystr stream.

<!-- app/views/site/first.html.erb -->
<%= turbo_stream_from "mystr" %>

<div class="w-full text-center my-8">
  <%= button_to "Load stuff!", fourth_page_path, class: "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 mr-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" %>
</div>

<div class="my-8 w-full mx-auto" id="content">
  <%= render "the_stuff" %>
</div>

Clicking the button sends a POST request to the fourth action, which broadcasts a message through the mystr stream to all the browsers connected to it.

And we can even stream from the console by using the same Turbo::StreamsChannel module.

Turbo::StreamsChannel.broadcast_update_to("mystr", target: "content", html: "foooo")

And if I had a model, I could do the same from a model callback or any other method.

So let's summarize the differences.

FeatureTurbo FramesTurbo Streams
Easy to implement👍🏻👌🏻
Multiple updates👎🏻👍🏻
Works over WebSockets👎🏻👍🏻
Multiple actions👎🏻👍🏻
Lazy-loading👍🏻👎🏻
Caching👍🏻👎🏻

In terms of how easy they are to implement, Turbo Frames win because there's not that much you have to do. You just wrap sections in frames, and you're done.

But updating multiple sections at the same time is only supported by Turbo Streams.

Which also has WebSocket support, and it can do a lot more than just updates.

On the other hand, Turbo Frames are also suitable for progressively loading frames when a particular section of the page becomes visible.

Caching is another benefit that Turbo Frames bring to the table because it allows you to split a page up into multiple smaller page segments, each of which can be cached independently of the others.

12 Project Ideas
Cezar Halmagean
Software development consultant with over a decade of experience in helping growing companies scale large Ruby on Rails applications. Has written about the process of building Ruby on Rails applications in RubyWeekly, SemaphoreCI, and Foundr.