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
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.
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.
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.
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.
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.
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.
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.
- 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.
- The second problem is that we can only update the frame's content. We cannot delete, prepend, append, or anything other than just update.
- 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.
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 %>
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.
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.
Feature | Turbo Frames | Turbo 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.