In this article, I will be giving a brief tutorial on how Hotwire Turbo Frames and Streams work in a Rails 7 CRUD application.
For the purpose of making this article brief, I have gone ahead and setup a very simple CRUD application, where there is an index page with many posts. Posts have an attribute of body, and all basic CRUD actions are implemented — index, show, new, create, edit, update, delete. Rails 7 comes with Turbo out of the box, so you don’t need to worry about setting it up.
#posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.all
end
def show
@post = Post.find(params[:id])
end
def new
@post = Post.new
end
def create
@post = Post.new(post_params)
if @post.save
redirect_to root_path
else
render :new
end
end
def edit
@post = Post.find(params[:id])
end
def update
@post = Post.find(params[:id])
if @post.update(post_params)
redirect_to root_path
else
render :edit
end
end
def destroy
@post = Post.find(params[:id])
@post.destroy
redirect_to root_path
end
private
def post_params
params.require(:post).permit(:body)
end
end
I have also made very simple views for each action, where I am simply displaying the data, with links to Edit, Delete and Back where appropriate.
<!--index.html.erb-->
<h1>Posts Index</h1>
<%= link_to "New Post", new_post_path %><br><br>
<div class="flex-container">
<%= render @posts %>
</div>
<!--_post.html.erb-->
<div class="post">
<h3><%= link_to post.body, post_path(post) %><br><br></h3>
<%= link_to "Edit", edit_post_path(post) %>
<%= link_to "Delete", post_path(post), data: { "turbo-method": :delete } %>
</div>
As you can see, our application redirects to another page when we want to create a new post, or edit an existing post. This is something that we can change, by using Turbo frames and streams. We can avoid this context switch, and render our new and edit forms right here on our index page.
Now, what are Turbo Frames ?
Turbo Frames are independent pieces of code in our application, and they can be appended, prepended, replaced, or removed without the need of a full page refresh.
Lets go ahead and add our first turbo frame tag around our New Post link.
<!--index.html.erb-->
<h1>Posts Index</h1>
<%= turbo_frame_tag "new_post" do %>
<%= link_to "New Post", new_post_path %><br><br>
<% end %>
<div class="flex-container">
<%= render @posts %>
</div>
If we inspect the DOM, we can see that this creates a new custom element, a turbo-frame with the id which we specified. It intercepts form submissions and clicks on links within the frame.
Now the main thing we need to remember about turbo frame elements. When clicking on a link within a turbo frame, Turbo will expect another frame of the exact same id, so it can replace the contents of the clicked turbo frame with the other frame’s content.
Keeping this information in mind, lets go ahead and add a turbo frame tag in our New template where the form is rendered.
<!--new.html.erb-->
<h1>New Post</h1>
<%= turbo_frame_tag "new_post" do %>
<%= render "form", post: @post %>
<% end %>
<%= link_to "Back to posts", posts_path %>
Now go ahead and click that New Post link on the index page. You will notice that there is not a full page reload, and instead our form replaces that link. Always remember to have matching ids where turbo frames are concerned, as these are where most of the runtime errors originate from.
Even from this simple example, the power of Turbo can be seen. Lets now try to implement this in the Edit feature. The expected behavior would be that when the post edit button is pressed, the post template gets replaced with the form, and when submitted, it is replaced again with the updated post.
We will need a unique id for each post to use in the turbo frames. Thankfully, in the DOM, each post generated already has a unique id, like ‘post_13’, and Rails has a helper function, dom_id(post) to do this. Note that we could write a unique id for each post ourselves by appending a string with the post id, but it is always a good idea to use helper functions that Rails offers.
<!--_post.html.erb-->
<%= turbo_frame_tag dom_id(post) do %>
<div class="post">
<h3><%= link_to post.body, post_path(post) %><br><br></h3>
<%= link_to "Edit", edit_post_path(post) %>
<%= link_to "Delete", post_path(post), data: { "turbo-method": :delete } %>
</div>
<% end %>
<!--edit.html.erb-->
<h1>Edit Post</h1>
<%= link_to "Back to posts", posts_path %>
<%= turbo_frame_tag dom_id(@post) do %>
<%= render "form", post: @post %>
<% end %>
Go ahead and try to edit a post. The whole post element will be replaced with the form, exactly like we specified, and when submitting the form, the reverse action will occur and our form will be replaced with the updated post.
However, now our show button does not work as expected. And we should have seen this coming, as we are not specifying Turbo any id. The show must lead to another page, meaning the contents of the whole page must updated. Do we need to put a frame tag around the whole index page ? No! Turbo offers a very useful frame tag, _top, which refers to the whole window. This will suit our needs.
<!--_post.html.erb-->
<%= turbo_frame_tag dom_id(post) do %>
<div class="post">
<h3><%= link_to post.body, post_path(post), data: { turbo_frame: "_top" } %><br><br></h3>
<%= link_to "Edit", edit_post_path(post) %>
<%= link_to "Delete", post_path(post), data: { "turbo-method": :delete } %>
</div>
<% end %>
Remember, Turbo has inserted the matching id for this frame itself, so we don’t need to do it.
Let us deal with another unexpected behavior. The delete button does not work. We want this to be independent so it does not disturb anything else on the page. This is where Turbo Streams come in handy. Following are the various Turbo Stream methods we can use where appropriate. In a destroy action, the remove method should be used.
# Remove a Turbo Frame
turbo_stream.remove
# Insert a Turbo Frame at the beginning/end of a list
turbo_stream.append
turbo_stream.prepend
# Insert a Turbo Frame before/after another Turbo Frame
turbo_stream.before
turbo_stream.after
# Replace or update the content of a Turbo Frame
turbo_stream.update
turbo_stream.replace
Observe in the console logs that when the delete action is called, it is processed as TURBO_STREAM, not HTML. Using this knowledge, we will use the respond_to method in the controller, to tell the controller how to behave when dealing with different types of requests.
#posts_controller.rb
def destroy
@post = Post.find(params[:id])
@post.destroy
respond_to do |format|
format.html { redirect_to posts_path }
format.turbo_stream
end
end
Note we are not redirecting anywhere in case of a TURBO_STREAM request, so the controller will expect a view of the same name as the action. Lets go ahead and make it.
<!--destroy.turbo_stream.erb-->
<%= turbo_stream.remove dom_id(@post) %>
What are we doing here ? Simple. We are just telling turbo stream to remove a post with an id specified. The delete button now works fine. Turbo is amazing, right ? With so little code we can do so much, which would otherwise require loads of JavaScript.
Lets add a turbo stream method for our new action as well. What must happen is that clicking on the new post button does not redirect us to another page, and while staying on the same page, when we submit the form, the new post is prepended/appended to the list of posts. Lets give it a try.
<!--new.html.erb-->
<h1>New Post</h1>
<%= link_to "Back to posts", posts_path %>
<%= turbo_frame_tag @post do %>
<%= render "form", post: @post %>
<% end %>
Note that I used the post variable instead of dom_id(post). Well, to Turbo, they will be the same thing. So our code can look a little better. This variable also refers to a new Post variable, which is the same as Post.new.
<!--index.html.erb-->
<h1>Posts Index</h1>
<%= link_to "New Post", new_post_path, data: { turbo_frame: dom_id(Post.new) } %><br><br>
<div class="flex-container">
<%= turbo_frame_tag Post.new %>
<%= render @posts %>
</div>
Let see what happened here. I made an empty turbo frame tag which will be replaced with the new post. And the New Post link will be replaced with the form, because when doing Post.new, the body attribute is missing, and so in the controller, it renders the new action.
def create
@post = Post.new(post_params)
if @post.save
redirect_to root_path
else
render :new
end
end
But there is one last problem. When the new post is created, we don’t see it unless we perform a full refresh of the browser. Of course this is happening. How would Turbo know where to prepend our new post ? Tell tell it in the code.
def create
@post = Post.new(post_params)
if @post.save
respond_to do |format|
format.html { redirect_to posts_path, notice: "Post Created" }
format.turbo_stream
end
else
render :new, status: :unprocessable_entity
end
end
Again, our controller will expect a view with the same name as our action to render.
<!--create.turbo_stream.erb-->
<%= turbo_stream.prepend "posts", partial: "post/post", locals: { post: @post } %>
<%= turbo_stream.update Post.new, "" %>
We can shorten this syntax, as Rails will understand that that these expressions are equivalent.
<!--create.turbo_stream.erb-->
<%= turbo_stream.prepend "posts", @post %>
<%= turbo_stream.update Post.new, "" %>
Now the last thing that needs to be done is do add a turbo frame tag in index, with the id of “posts”, to match the id that we gave in the create.turbo_stream.erb view.
<!--index.html.erb-->
<h1>Posts Index</h1>
<%= link_to "New Post", new_post_path, data: { turbo_frame: dom_id(Post.new) } %><br><br>
<div class="flex-container">
<%= turbo_frame_tag Post.new %>
<%= turbo_frame_tag "posts" do %>
<%= render @posts %>
<% end %>
</div>
Now go ahead and test it by creating a new post. The new post is automatically prepended to the list of posts, without the need of a full refresh. With this little amount of code, we achieved so much. This is a very simple example. The power Turbo can be utilized in many applications like this one.
There can be many use cases for turbo stream actions, like using turbo_stream.replace or turbo_stream.update in the edit or delete actions. Now if we want to take this further, and added another nested CRUD of comments, and wanted to use Turbo actions with this. Now we will need to use the unique dom id of each Post turbo frame, to add or edit comments in a specific post, same as the other CRUD actions.