---
title: Hotwire and That Syncing Feeling
teaser: How to create a multi-view presentation that stays in sync.
tags: hotwire,ruby,rails,ruby on rails,development
author: Louis Antonopoulos
published_on: 2025-03-13
---

Recently, I had to implement a multi-screen slide deck presentation system
that stayed in sync with the presenter's view.

How did I get there?

## Requirements

Render three views: the group view, the individual view, and the presenter view.

- The **group view** should show a fullscreen view of a slide, including group content such as a video
- The **individual view** should show a fullscreen view of a slide with the option to take actions
  such as filling out a form, but should hide group content
- The **presenter view** should show a "preview" of the previous, current, and next slides
- The **presenter view** can navigate forward, backward, or jump to any slide

and most importantly, **all views have to stay in sync with the presenter's current slide.**

Also, this isn't syncing only three instances. In our environment it was around 30, as we had
that many people with the individual view running on their laptop!

## Routes and views

The foundation of the system is a `Presentation` table with a `#current_slide` column.

To simplify things a bit, the system supports three routes:

```
/presentations/:id
/presentations/:id/group
/presentations/:id/individual
```

Without getting into too much detail, the group and individual routes have a
`show.html.erb` that uses a `@presentation` reference to display the content
for the current slide.

The presenter view renders three iframes of the group view:

- one for `#current_slide - 1`
- one for `#current_slide`
- one for `#current_slide + 1`

The iframes are wrapped in buttons that update `#current_slide` and use
`pointer-events: none` in the CSS to prevent the presenter from accidentally interacting with
the previews.

But how to keep all the views in sync?

## Can Hotwire help us? Yes!

We can leverage
[broadcasting page refreshes](https://turbo.hotwired.dev/handbook/page_refreshes#broadcasting-page-refreshes)
by adding `broadcasts_refreshes` to our `Presentation` model and
`turbo_stream_from` to our views.

## Getting it working in Development

To tie everything together, we have to make a handful of one- or two-line changes to:

- the model
- the view
- the layout

```ruby
# app/models/presentation.rb

class Presentation < ApplicationRecord
  broadcasts_refreshes
```

```erb
<!-- app/views/presentations/group/show.html.erb -->

<%= turbo_stream_from @presentation %>
  <div id="<%= dom_id(@presentation) %>">
    <div>Group View: Slide <%= @presentation.current_slide %></div>
```

```erb
<!-- app/views/presentations/individual/show.html.erb -->

<%= turbo_stream_from @presentation %>
  <div id="<%= dom_id(@presentation) %>">
    <div>Individual View: Slide <%= @presentation.current_slide %></div>
```

We also need to update our `Application` layout to
[refresh with morphing](https://turbo.hotwired.dev/handbook/page_refreshes):

```erb
<!-- app/views/layouts/application.html.erb -->

<!DOCTYPE html>
<html lang="en">
  <head>
    ...
    <%= csp_meta_tag %>

    <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
```

### Using `content_for` instead of an application-wide layout

Some sites won't want Turbo refreshes on all pages. To enable morphing on a specific
view only, instead of adding the `turbo_refreshes_with` helper to the application layout,
add `turbo_refreshes_with` anywhere in a view's `html.erb` file.

```erb
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
```

This will automatically add `turbo_refresh_method_tag` and `turbo_refresh_scroll_tag`
to the `<head>` element of that view.

A note from the
[Rails guides](https://guides.rubyonrails.org/layouts_and_rendering.html#understanding-yield):

> Newly generated applications will include `<%= yield :head %>` within the `<head>` element
of the `app/views/layouts/application.html.erb` template.

If you have updated an older Rails app, you may need to add the `yield` yourself:

```erb
<!-- app/views/layouts/application.html.erb -->

<!DOCTYPE html>
<html lang="en">
  <head>
    ...
    <%= csp_meta_tag %>

    <%= yield :head %>
```

### The broadcasting part

With this setup, when `Presentation#current_slide` is updated, the change is broadcast to the
listening views, and they ~~magically~~ automatically show the content for the current slide!

The overall approach requires updating the `Presentation` record, but more specifically,
it relies on an ActiveRecord callback. At first, we tried the seemingly-convenient
[increment!](https://api.rubyonrails.org/v8.0.1/classes/ActiveRecord/Persistence.html#method-i-increment-21")
and
[decrement!](https://api.rubyonrails.org/v8.0.1/classes/ActiveRecord/Persistence.html#method-i-decrement-21")
methods to update the current slide.

The issue with these (and a few other ActiveRecord methods) is that **they do not trigger callbacks**.
And so, they never broadcast a change, and the views never update!

<aside class="info">
Submitting a form with Turbo morphing on can create some unexpected woes, such as the form not
clearing itself on a POST.

Check out
<a href="https://thoughtbot.com/blog/turbo-morphing-woes">Turbo morphing woes</a>
by
<a href="https://thoughtbot.com/blog/authors/matheus-richard">Matheus Richard</a>
to learn how to make those woes go away!
</aside>

## Getting it working in Test

<aside class="info">
Testing Hotwire requires a system test, and Hotwire-dependent tests
can be prone to flakiness, but reducing that flakiness is a story for another day.
</aside>

Here's a simple system test to verify that the broadcasting is working as expected:

```ruby
# test/system/presentation_test.rb

test "/presentations/:id/group dynamically updates its content" do
  presentation = create :presentation, current_slide: 1

  visit presentation_group_path(presentation)

  assert_text "Slide 1"

  presentation.update!(current_slide: 2)

  assert_text "Slide 2"
end
```

...but if you run this on a default Rails setup, it will never work.
Why? Because behind the scenes, the broadcasts are handled by jobs.
And in Test, jobs are configured by default to use the
[TestAdapter](https://api.rubyonrails.org/classes/ActiveJob/QueueAdapters/TestAdapter.html).

So, you have two choices:

- Use the `:async` adapter in test, include the `ActiveJob::TestHelper` in your test file,
  and use a `perform_enqueued_jobs` block around the `#update!` call:

```ruby
# config/environments/test.rb

config.active_storage.service = :async
```

```ruby
# test/system/presentation_test.rb

include ActiveJob::TestHelper

test "/presentations/:id/group dynamically updates its content" do
  presentation = create :presentation, current_slide: 1

  visit presentation_group_path(presentation)

  assert_text "Slide 1"

  perform_enqueued_jobs do
    presentation.update!(current_slide: 2)
  end

  assert_text "Slide 2"
end
```

- or, use the `:inline` adapter but swap it out for the test adapter in your `test_helper.rb` file
  so that `ActiveJob` tests that rely on enqueued jobs continue to work:

```ruby
# config/environments/test.rb

config.active_storage.service = :inline
```

```ruby
# test/test_helper.rb

module ActiveJob
  module TestHelper
    def queue_adapter_for_test
      ActiveJob::QueueAdapters::TestAdapter.new
    end
  end
end
```

with no changes to the first system test example above.

## Getting it working in Production (Heroku)

There are many ways you might be handling jobs in your Production environment,
but in Heroku, we had to create a separate dyno running `bundle exec bin/jobs`:

![A list of two dynos in Heroku, one running puma, the other running bin/jobs.](https://images.thoughtbot.com/9094etabzp9i570c9f0u666ucsdf_CleanShot%202025-03-03%20at%2015.16.12.png)

## Final thoughts

Thanks to `broadcasts_refreshes`, `turbo_stream_from`, and Turbo morphing, we can keep a
robust multi-screen system that stays in sync with the presenter's view with only
a few tiny additions to our code.

The possibilities are endless, but the core idea is the same: any view can reflect the
state of a model with Hotwire.

There _are_ some gotchas to keep in mind, but other than that, it's as easy as...syncing!
