Conditionally render a Turbo Frame shared between multiple views

You are being redirected to https://thoughtbot.com/blog/conditionally-render-turbo-frame

The Turbo Frames API requires that a request made from within a turbo-frame must receive a response containing a corresponding turbo-frame of the same id.

Because Rails encourages the reuse of partials and views, this can lead to situations where you need to conditionally render a Turbo Frame. One such example is inline editing, which we’ll explore in this tutorial.

Our Base

Our starting point does not yet warrant the need to conditionally render any of the Turbo Frames because all three instances use the same HTML. Most notably between the show and index views. This is because both of those views render the _post partial.

Editing a post inline, from the index viewClick to expand

# app/views/posts/index.html.erb

<% @posts.each do |post| %>
  <%= turbo_frame_tag dom_id(post) do %>
    <%= render post %>
    <%= link_to "Edit", edit_post_path(post) %>
  <% end %>
<% end %>
# app/views/posts/_post.html.erb

<div>
  <p>
    <strong>Title:</strong>
    <%= post.title %>
  </p>

  <p>
    <strong>Body:</strong>
    <%= post.body %>
  </p>

</div>

When we click the “Edit” link from the index view, we load the corresponding turbo-frame from the edit view. When the form is submitted, the #update action redirects to the show view, which also contains a corresponding turbo-frame.

# app/views/edit.html.erb

<%= turbo_frame_tag dom_id(@post) do %>
  <%= render "form", post: @post %>
  <%= link_to "Cancel", :back %>
<% end %>
def update
  if @post.update(post_params)
    redirect_to post_url(@post), notice: "Post was successfully updated."
  else
    render :edit, status: :unprocessable_entity
  end
end
# app/views/posts/show.html.erb

<%= turbo_frame_tag dom_id(@post) do %>
  <%= render @post %>
  <%= link_to "Edit", edit_post_path(@post) %>
<% end %>

The key here is that the index and show views are both using the same _post partial. This makes for a seamless experience. The only “gotcha” is that this also means we’ve inadvertently enabled inline editing on the show page too.

Editing a post inline, from the edit viewClick to expand

However, this is a contrived example and does not reflect a real-world design. It’s common to render content differently when viewed in different contexts.

Let’s explore that next.

The Problem

Let’s update our _post partial and show view so that we see a teaser of the post on the index page, and the full post on the show page.

--- a/app/views/posts/_post.html.erb
+++ b/app/views/posts/_post.html.erb
@@ -1,12 +1,12 @@
-<div>
-  <p>
-    <strong>Title:</strong>
-    <%= post.title %>
-  </p>
+<article>
+  <h2><%= post.title %></h2>

   <p>
-    <strong>Body:</strong>
-    <%= post.body %>
+    <%= post.body.truncate(20) %>
   </p>

-</div>
+  <td>
+    <%= link_to "Edit", edit_post_path(post) %>
+    <%= link_to "Show this post", post, data: { turbo_frame: "_top" } %>
+  </td>
+</article>
--- a/app/views/posts/index.html.erb
+++ b/app/views/posts/index.html.erb
@@ -8,11 +8,7 @@
   <% @posts.each do |post| %>
     <%= turbo_frame_tag dom_id(post) do %>
       <%= render post %>
-      <%= link_to "Edit", edit_post_path(post) %>
     <% end %>
-    <p>
-      <%= link_to "Show this post", post %>
-    </p>
   <% end %>
 </div>

--- a/app/views/posts/show.html.erb
+++ b/app/views/posts/show.html.erb
@@ -1,7 +1,10 @@
 <p style="color: green"><%= notice %></p>

 <%= turbo_frame_tag dom_id(@post) do %>
-  <%= render @post %>
+  <h1><%= @post.title %></h1>
+  <p>
+    <%= @post.body %>
+  </p>
   <%= link_to "Edit", edit_post_path(@post) %>
 <% end %>

Now that we’re no longer sharing the same markup between the index view and the show view, we end up rendering the markup for the show view when we edit a post from the index view. Instead of rendering a teaser, we render the whole post.

Editing a post inline from the index view results in the full post being rendered after submitting the formClick to expand

However, this is not an issue when editing from the edit page, since we expect to see the whole post after making an edit.

Editing a post inline from the edit view does not result in a disjointed UIClick to expand

A Simple Solution

Here’s where we need introduce the concept of conditionally rendering a Turbo Frame.

What we want to do is render the simple _post partial when a request is made from the index view. Otherwise, if the request is made from the edit view, we want to render the show view.

Fortunately, this can be easily solved with redirect_back_or_to.

Redirects the browser to the page that issued the request (the referrer) if possible, otherwise redirects to the provided default fallback location.

--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -33,7 +33,7 @@ class PostsController < ApplicationController
   # PATCH/PUT /posts/1 or /posts/1.json
   def update
     if @post.update(post_params)
-      redirect_to post_url(@post), notice: "Post was successfully updated."
+      redirect_back_or_to post_url(@post), notice: "Post was successfully updated."
     else
       render :edit, status: :unprocessable_entity
     end

In this case, when we edit a post from the index view, it will respond with index which already has a turbo-frame rendering the _post partial. The same concept applies for when editing a post from the edit view.

Editing the post inline from the index view results in the teaser being rendered after submissionClick to expand

A More Complex Example

Our current implementation only works because there’s a turbo-frame on the index, show and edit views. What if we didn’t have that luxury? For example, what if we didn’t want to inline edit on the show page?

We can’t use redirect_back_or_to because we want to redirect to the show view when making an edit on the edit view, but still maintain inline editing on the index view.

Fortunately, we can leverage variants in concert with parameters to conditionally render our Turbo Frames based on specific context.

First, we can update our _post partial by having it link to the edit view, but with a query string of ?variant=inline.

--- a/app/views/posts/_post.html.erb
+++ b/app/views/posts/_post.html.erb
@@ -6,7 +6,7 @@
   </p>

   <td>
-    <%= link_to "Edit", edit_post_path(post) %>
+    <%= link_to "Edit", edit_post_path(post, variant: :inline) %>
     <%= link_to "Show this post", post, data: { turbo_frame: "_top" } %>
   </td>
 </article>

This means that when the request is made, we’ll have the additional context about how we want to render this response.

Now that we’ve encoded the context into the URL, we need to do something with it. We can start by first creating a new variant for the edit view that will include the turbo-frame.

# app/views/posts/edit.html+inline.erb

<% content_for :title, "Editing post" %>

<h1>Editing post</h1>

<%= turbo_frame_tag dom_id(@post) do %>
  <%= render "form", post: @post %>
  <%= link_to "Cancel", :back %>
<% end %>

Since we’re loading the form on this page, we can conditionally set a hidden field to capture this value and pass it over to the #update action so it is informed of the context as well.

--- a/app/views/posts/_form.html.erb
+++ b/app/views/posts/_form.html.erb
@@ -21,6 +21,10 @@
     <%= form.text_area :body %>
   </div>

+  <% if params[:variant] == "inline" %>
+    <%= hidden_field_tag :variant, "inline", readonly: true %>
+  <% end %>
+
   <div>
     <%= form.submit %>
   </div>

Now that we have a variant responsible for including the turbo-frame in a variant, we can remove it from the base edit view.

--- a/app/views/posts/edit.html.erb
+++ b/app/views/posts/edit.html.erb
@@ -2,11 +2,7 @@

 <h1>Editing post</h1>

-
-<%= turbo_frame_tag dom_id(@post) do %>
-  <%= render "form", post: @post %>
-  <%= link_to "Cancel", :back %>
-<% end %>
+<%= render "form", post: @post %>

 <br>

Now we just need to apply the same changes to the show views so that the update action can conditionally render the appropriate variant based on the query parameter.

Similar to the above, we can create a variant for the show view that will contain a turbo-frame.

# app/views/posts/show.html+inline.erb

<%= turbo_frame_tag dom_id(@post) do %>
  <%= render @post %>
<% end %>

This means we can remove it from the base show view.

--- a/app/views/posts/show.html.erb
+++ b/app/views/posts/show.html.erb
@@ -1,12 +1,10 @@
 <p style="color: green"><%= notice %></p>

-<%= turbo_frame_tag dom_id(@post) do %>
-  <h1><%= @post.title %></h1>
-  <p>
-    <%= @post.body %>
-  </p>
-  <%= link_to "Edit", edit_post_path(@post) %>
-<% end %>
+<h1><%= @post.title %></h1>
+<p>
+  <%= @post.body %>
+</p>
+<%= link_to "Edit", edit_post_path(@post) %>

 <div>
   <%= link_to "Back to posts", posts_path %>

Now that we’ve modified the views, we need to update our controller to conditionally chose the correct variant based on the parameters.

--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -1,5 +1,6 @@
 class PostsController < ApplicationController
   before_action :set_post, only: %i[ show edit update destroy ]
+  before_action :set_variant, only: %i[ show edit update ]

   # GET /posts or /posts.json
   def index
@@ -8,6 +9,7 @@ class PostsController < ApplicationController

   # GET /posts/1 or /posts/1.json
   def show
+    request.variant = @variant
   end

   # GET /posts/new
@@ -17,6 +19,7 @@ class PostsController < ApplicationController

   # GET /posts/1/edit
   def edit
+    request.variant = @variant
   end

   # POST /posts or /posts.json
@@ -33,7 +36,7 @@ class PostsController < ApplicationController
   # PATCH/PUT /posts/1 or /posts/1.json
   def update
     if @post.update(post_params)
-      redirect_back_or_to post_url(@post), notice: "Post was successfully updated."
+      redirect_to post_url(@post, variant: @variant), notice: "Post was successfully updated."
     else
       render :edit, status: :unprocessable_entity
     end
@@ -56,4 +59,8 @@ class PostsController < ApplicationController
     def post_params
       params.require(:post).permit(:title, :body)
     end
+
+    def set_variant
+      @variant ||= :inline if params[:variant] == "inline"
+    end
 end

With this change in place, making edits on the index view returns the teaser content.

A teaser is still rendered when making edits from the index viewClick to expand

This change also means making edits from the edit page no longer happen inline, as made evident by the presence of the flash message.

We redirect to the show page after making an edit on the edit view.Click to expand

Wrapping Up

Turbo Frames require a new mental model when it comes to managing the state of a page. That, plus that fact that it’s a relatively new technology means that we’re still exploring solutions to common problems as a community.

In this case, Turbo does not offer an off-the-shelf solution to conditionally rendering Frames, but Rails does. I hope that moving forward, this post will serve as guide when others are faced with the same problem.