Conditional Rendering With Turbo Stream Broadcasts
Tags: rails, ruby, webdev, tutorial
Published: 2021-08-16 13:11:24

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](https://github.com/varvet/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](https://discuss.hotwired.dev/t/authentication-and-devise-with-broadcasts/1752/4). 

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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ilame9e63eio1h2l4qb3.gif)

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](https://www.colby.so/posts/remotely-loading-tab-content-with-rails-and-hotwire) might be a better introduction.

As usual, you'll find the full code for this article on [GitHub](https://github.com/DavidColby/hotwire-spies).

Ready? Let’s get started.

## Application Setup

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

```shell
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:

```erb
<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:

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

And fill it in with:

```erb
<%= 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:

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

And fill that it in:

```erb
<%= 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:

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

In your browser, head to [http://localhost:3000/spies](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:

```ruby
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:

```ruby
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](https://discuss.hotwired.dev/t/proposal-frame-reloads-through-streams/2209) 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:

```ruby
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:

```erb
<%= 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:

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

Then in the controller, add the new `clearance` method:

```ruby
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.

```erb
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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jk3knheli5l5pmkpdcs8.gif)

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:

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

Fill that new partial in with the following content:

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

Here we’re taking advantage of the fact that Turbo Frames [can be given](https://turbo.hotwired.dev/reference/frames#lazy-loaded-frame) 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:

```ruby
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:

```ruby
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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cb3wsilhs4ft70qbtd1y.gif)

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](https://discuss.hotwired.dev/t/proposal-frame-reloads-through-streams/2209/7). 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!