Conditional Rendering With Turbo Stream Broadcasts

A very common pattern in Rails development is for a view to contain checks for things like current_user.can?(:take_some_action). These types of checks are common, especially in B2B applications that implement role-based permissions powered by a solution like Pundit.

So, naturally, when a Rails developer begins working with Turbo Stream broadcasts, they wonder how to access the current_user or other session-level variables. The short answer? You can’t.

Partials rendered via a stream broadcast have to be free of global variables or they’ll throw an error because they are not rendered within the context of a specific request. Without that request context variables like current_user will always be undefined.

While we can’t access global variables from a broadcast, we can, with creative use of Turbo Frames, still deliver real-time broadcasts that retain access to key variables like current_user.

To demonstrate the concept today we’re going to build a simple application that displays a list of Spies.

Each spy will have a mission; however, not everyone will be able to see the spy’s mission. Only visitors with secret clearance can see the mission field. Everyone else will see a “Classified” string in place of the mission.

When a new spy is created, we’ll broadcast an update to any viewers of the spy list. Viewers with clearance will see the mission for the newly created spy, viewers without won’t.

The end result will work like this:

A screen recording of a user with two windows open to the same webpage. The webpage displays a list of names, with a header that reads Spies. In one window the user sees a message that they have secret clearance, in the other they don't. The user creates a new spy by entering a name and the new spy ges added to the list of spies already on the page. In the window with secret clearance, the user sees newly created spy's mission, in the other, they don't.

This article will be most useful for folks who are already comfortable with Ruby on Rails and who have a little experience with Turbo already. If you’re comfortable with Rails but you’ve never used Turbo before, an article like this might be a better introduction.

As usual, you’ll find the full code for this article on GitHub.

Ready? Let’s get started.

Application Setup

Let’s get our application setup first, from your terminal:

rails new hotwire-spies -T
cd hotwire-spies
bundle add hotwire-rails
rails hotwire:install
rails g scaffold Spy name:string mission:string
rails db:migrate 
  

Setup Frames

We’re going to make some updates to the Spy index view that our scaffold generated to incorporate a few Turbo Frames and prepare to use broadcasts to append new records.

Open spies/index.html.erb and update it:

<p id="notice"><%= notice %></p>

<h1>Spies</h1>

<%= turbo_stream_from "spies" %>
<div id="spies">
  <% @spies.each do |spy| %>
    <%= render spy, spy: spy %>
  <% end %>
</div>

<br>
<%= render "spy_link" %>
  

Here we’re subscribing to the spies turbo stream channel, setting an id on the container div for our rendered spies, and then looping through and rendering the spy partial for each spy.

The default scaffold generator doesn’t create a spy partial for us, so we need to create that:

touch app/views/spies/_spy.html.erb
  

And fill it in with:

<%= turbo_frame_tag spy do %>
  <div>
    <%= spy.name %> | Current mission: <%= spy.mission %>
  </div>
<% end %>
  

We’re also rendering a new spy_link partial, which doesn’t exist yet. Go ahead and create it now:

touch app/views/spies/_spy_link.html.erb
  

And fill that it in:

<%= turbo_frame_tag "new_spy" do %>
  <%= link_to 'New Spy', new_spy_path %>
<% end %>
  

Wrapping the new spy link in a dedicated frame means that when the link is clicked, we can replace the link on the page with the content returned from the call to /spies/new.

To make that work, we’ll need to update new.html.erb next to render its content in a matching <turbo-frame> tag:

<%= turbo_frame_tag "new_spy" do %>
  <%= render 'form', spy: @spy %>
<% end %>
  

In your browser, head to http://localhost:3000/spies and you should be able to click on the New Spy link and see the spy form render. Fill in a name and give your spy a mission, submit the form, and see that the form disappears but the spy doesn’t render on the page and the new spy link doesn’t come back.

Our spy creation form POST succeeds, but we aren’t sending back a useful response from the controller or broadcast any updates on the Spies channel.

Let’s finish up spy creation by tackling those issues next.

Handling spy creation

When our new spy form is submitted, a turbo stream request is sent to the server, and right now our server isn’t responding back with anything useful. Let’s fix that first by heading to the SpiesController and updating the create method:

def create
  @spy = Spy.new(spy_params)

  respond_to do |format|
    if @spy.save
      # Our addition
      format.turbo_stream { render turbo_stream: turbo_stream.replace("new_spy", partial: "spy_link")}
      format.html { redirect_to @spy, notice: "Spy was successfully created." }
    else
      format.html { render :new, status: :unprocessable_entity }
      format.json { render json: @spy.errors, status: :unprocessable_entity }
    end
  end
end
  

Now our create method will respond to turbo_stream requests by replacing the new_spy frame with the content in the spy_link partial.

Try creating a new spy now and, after a successful POST, the form should be removed from the page and the New Spy link should appear in its place.

Finally, we need to append the newly created spy to the list of spies without the user needing to refresh the page.

We’ll do that with a broadcast from the Spy model:

after_create_commit { broadcast_append_to('spies', target: 'spies', locals: { spy: self }) }
  

This callback runs after a new spy is created and sends a broadcast on the Spies channel containing a Turbo Stream element that looks like this <turbo-stream action="append" target="spies">. Nested within the stream element is the with the content of the spy partial

Now when we create a new spy, the new record should instantly be added to the list of spies, no refresh required.

With all of this in place, we can move to the really fun part of this article, conditionally rendering content for different users from a Turbo Stream broadcast.

Let’s see how it works.

Conditional rendering from a broadcast

First, let’s remember that our original goal was that each spy’s mission would only be displayed to users with secret clearance. Everyone else should just see “Classified” displayed in place of the mission text.

This is simple to do on the initial page load, but since we’re broadcasting new agent creation via a WebSocket connection, things are a little more complicated. Without access to the user’s session, we’ll have to get creative.

To meet our secret clearance requirement, we’re going to use a technique that I first saw described on the Hotwire discussion forums that allows us to bypass the need to access session-level variables in partials rendered by a broadcast.

We’ll implement this requirement by adding a check for a session variable into the spy partial that our broadcast renders. When the session variable exists, the user will see mission text, otherwise the user will not.

This session variable is a rough mock of what, in a real application, might be accessed through something like Current.user

To start, we’ll add a helper method to our ApplicationController to make it easy to access this session variable:

class ApplicationController < ActionController::Base
  helper_method :secret_clearance

  def secret_clearance
    session[:clearance].presence || false
  end
end
  

Then we’ll reference that helper method in spies/_spy.html.erb like this:

<%= turbo_frame_tag spy do %>
  <div>
    <%= spy.name %> | Current mission: <%= secret_clearance ? spy.mission : "Classified" %>
  </div>
<% end %>
  

We’ll need a way to set the value of clearance for different users. To do that, we’ll add a new endpoint to the SpiesController.

First, update our routes file:

resources :spies do
  collection do
    post 'clearance'
  end
end
  

Then in the controller, add the new clearance method:

def clearance
  secret_clearance ? session.delete(:clearance) : session[:clearance] = true
  redirect_to spies_path
end
  

Then, we’ll add a way to the set the clearance session variable from the index page in our extremely secure spy application.

Your current clearance is <%= secret_clearance ? "Secret" : "None" %>
<%= link_to "Change clearance", clearance_spies_path, method: :post %>
  

Before we move on, create a new spy from the Spies index page and see that secret clearance is always false when broadcasting through a channel, even when the current session has secret clearance set to true.

A screen recording of a user with on a webpage. The webpage has a header that reads Spies, and lists the names of a few spies. At the top of the page, text informs the user that they have secret clearance. The user types in the name and mission of a new spy and the new spy is added to the page, but the spy's mission is listed as classified.

This is happening because the value of session when rendering from a broadcast is an empty hash. The session isn’t read from the user’s current session, and so our secret_clearance check will always fail.

To fix this issue and ensure users always see what they’re supposed to see, we’ll start by creating a new partial:

touch app/views/spies/_spy_frame.html.erb
  

Fill that new partial in with the following content:

<%= turbo_frame_tag spy, src: spy_path(spy, frame: true) %>
  

Here we’re taking advantage of the fact that Turbo Frames can be given an src attribute which causes the frame load its content from a URL when it is inserted onto the page.

In this case, the frame will retrieve the content from spies#show. We’ll update that method in the SpiesController next:

def show
  if request.headers["turbo-frame"]
    render partial: 'spy', locals: { spy: @spy }
  else
    render 'show'
  end
end
  

Our show method checks the request headers to see if the request includes a turbo-frame header. When the frame header is present, the existing spy partial is rendered.

Finally, update our model broadcast to render the new spy_frame partial:

after_create_commit { broadcast_append_to('spies', target: 'spies', partial: 'spies/spy_frame', locals: { agent: self }) }
  

With these changes in place, everything should work. To test it out, first make sure you’ve got two sessions active (open one in an incognito window). In one browser window, give the user clearance, in the other don’t.

Create a new spy in either session, and see that the broadcast triggers an update in both windows. In the window with clearance, the mission should display, in the window without clearance, Classified displays instead, like this:

A screen recording of a user with two windows open to the same webpage. The webpage displays a list of names, with a header that reads Spies. In one window the user sees a message that they have secret clearance, in the other they don't. The user creates a new spy by entering a name and the new spy ges added to the list of spies already on the page. In the window with secret clearance, the user sees newly created spy's mission, in the other, they don't.

So what’s going on here?

  1. The broadcast sends the empty, src powered frame from the spy_frame partial to all subscribed users and that frame is rendered safely since it does not request any session variables
  2. When the frame is inserted onto each user’s page, the src attribute on the frame immediately triggers an HTTP request to spies/:id
  3. Since the request is sent from the browser to an endpoint on the server, we have access to all the session data we expect in the rendered response
  4. The server responds to the request with the content of the spy partial which includes a <turbo-frame> with an id that matches the frame id of the empty frame in step 1.
  5. Since the frame ids match, Turbo replaces the content of the frame with the response from the server, and the new spy appears in the list.

With this trick, we can now make use of Turbo Stream broadcasts in situations where the rendered content changes based on non-local variables. Magical.

Wrapping up

Today we looked at a potential solution to a very common blocker for folks building more complex applications with Hotwire.

Real-world use cases for this approach include conditionally rendering edit/delete controls based on user permissions, or not displaying certain information to users without appropriate permissions inside of a multi-user account.

Other recommended approaches to these types of problems typically rely on using inline styles to hide/show content but that is not an acceptable solution for all use cases, particularly in more sensitive applications where the data being included in the markup but hidden on the page isn’t acceptable.

Although this method works well for some use cases, it certainly isn’t without downsides or limitations, which include:

  • Each broadcast now requires an additional round trip to the server. We’re gaining a lot of flexibility in return for additional load on our server but we are adding load and potentially slowing down our application. Be sure you need the extra flexibility gained by this approach.
  • The complexity of your code is increased. Following what’s happening in a broadcast takes a little more effort, even for the simplest possible use case outlined in this article.
  • If you’re using this method to update content that already exists on the page, you might see flickering issues. There are ways to address this, including one described in the discussion on this approach on the Hotwire forum. Depending on your UX needs, you could also include an “updating content” loading state in the frame broadcast.

When considering taking this approach in a production application, think about the tradeoff you’re making. We learned about a hammer today, but that doesn’t mean every problem related to session variables and broadcasts is a nail.

Sometimes, you might be better off with another approach like:

  • Broadcasting a generic “refresh to see changes” message
  • Using inline styles to conditionally show/hide information
  • Adjusting the content rendered in a broadcast to eliminate the need to access session variables
  • Scoping your streams appropriately to ensure that every recipient of a particular broadcast should see what is being broadcast
  • Not broadcasting updates at all. Sometimes, real-time visibility into changes isn’t helpful or necessary and folks will be comfortable seeing the updated information on the next page load.

While not without tradeoffs and potential drawbacks, this technique demonstrates some of the power that can be unlocked with creative applications of Turbo Streams and Frames in Rails applications. With Turbo still in early days, we can expect to see even more powerful techniques developed in the coming years to continue to push Turbo and Rails applications powered by Turbo ahead.

As always, thanks for reading!

Hotwiring Rails newsletter.

Enter your email to sign up for updates on the new version of Hotwiring Rails, coming in spring 2024, and a once-monthly newsletter from me on the latest in Rails, Hotwire, and other interesting things.

Powered by Buttondown.