Syncing a Slideshow across multiple sessions with Ruby on Rails + Hotwire

Nicolás Galdámez
Unagi
Published in
12 min readOct 25, 2023

In this article, we’re going to learn how to create an image slideshow using Ruby on Rails, Hotwire, and Tailwind CSS. We’ll also sync the navigation between the images across multiple windows.

Our goal is to achieve the following result:

Final Result

As you can see, there are two windows. On the left, a user who is logged in manages the slideshow navigation, while on the right, a user who is not logged in views the same slideshow and the images change automatically.

We’ll make this happen without writing any JavaScript code, using Ruby on Rails + Tailwind CSS + Hotwire.

You can find the code for everything described here in this GitHub repository.

Modeling the Data

To build the solution, we’ll work with just two tables: photos and users.

We'll use the users table to handle the user session, since the user controlling the photo navigation will be logged in.

For the photos, we’ll have a title and the photo’s URL. To do this, let’s create the associated model and run the migrations:

rails generate model Photo title url
rails db:migrate

As I mentioned, the person presenting the slideshow needs to be registered and logged in. To make this happen, we’ll use the devise gem:

bundle add devise
rails generate devise:install
rails generate devise User

With these commands, you will have devise configured and the users table created.

Building the Slideshow

For the slideshow, we will create a controller and an index action to display the first image:

rails generate controller slideshow index

This will create our controller with the index action and generate a view in app/views/slideshow/index.html.erb.

class SlideshowController < ApplicationController
def index
end
end

Let’s replace the code in app/views/slideshow/index.html.erb with the following code to display a slideshow featuring an image with a title, along with controls to navigate to the next/previous image:

<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<!-- IMAGE TITLE -->
<p class="my-2 text-center text-xl font-semibold text-gray-800">
EXAMPLE TITLE
</p>

<!-- IMAGE -->
<div class="relative overflow-hidden rounded-lg">
<img src="EXAMPLE_URL" class="w-full" />
</div>

<!-- NAV CONTROLS -->
<button class="absolute left-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4"/>
</svg>
</span>
</button>

<button class="absolute right-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none" />
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
</svg>
</span>
</button>
</div>
</div>

If you assign a valid URL to the src attribute of the <img> tag, you will see something like this:

Finally, we can tidy up the view by extracting the code for the photo and controls into a partial. Our index.html.erb will then look like this:

<!-- app/views/slideshow/index.html.erb -->
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<%= render 'photos/photo' %>
</div>
</div>

And here’s what our partial would look like:

<!-- app/views/photos/_photo.html.erb -->

<!-- IMAGE TITLE -->
<p class="my-2 text-center text-xl font-semibold text-gray-800">
EXAMPLE TITLE
</p>

<!-- IMAGE -->
<div class="relative overflow-hidden rounded-lg">
<img src="EXAMPLE_URL" class="w-full" />
</div>

<!-- NAV CONTROLS -->
<button class="absolute left-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4"/>
</svg>
</span>
</button>

<button class="absolute right-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none" />
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
</svg>
</span>
</button>

Displaying an Actual Photo

The example we’ve created so far has hardcoded information, which means it doesn’t get data from the database. To fix this, we need to update our controller to fetch a photo from the database and use it in the partial.

Let’s modify our controller to retrieve a photo from the database:

# app/controllers/slideshow_controller.rb
class SlideshowController < ApplicationController
def index
@photo = Photo.first
end
end

Let’s use the @photo variable in our index to render the app/views/photos/_photo.html.erb partial:

<!-- app/views/slideshow/index.html.erb -->
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<%= render @photo %>
</div>
</div>

Finally, let’s use the title and url attributes to display the information corresponding to the photo from the database:

<!-- app/views/photos/_photo.html.erb -->

<!-- IMAGE TITLE -->
<p class="my-2 text-center text-xl font-semibold text-gray-800">
<%= photo.title %>
</p>

<!-- IMAGE -->
<div class="relative overflow-hidden rounded-lg">
<%= image_tag photo.url, class: 'w-full' %>
</div>

<!-- NAV CONTROLS -->
...

This way, if you have photos in your database, when you access the index, the first photo will be displayed.

Navigating Between Photos

Now, the fun part starts! 🧐 Let’s make changes to allow the user to switch between photos. To do this, we’ll add the previous and next actions to our controller and use a Turbo Frame to update the currently displayed photo.

Adding a Turbo Frame

Since we want the image navigation to be seamless without reloading the entire page, we’ll add a Turbo Frame.
Here’s what the official documentation says about Turbo Frames:

Turbo Frames allow predefined parts of a page to be updated on request. Any links and forms inside a frame are captured, and the frame contents automatically updated after receiving a response. Regardless of whether the server provides a full document, or just a fragment containing an updated version of the requested frame, only that particular frame will be extracted from the response to replace the existing content.

Basically, with Turbo Frames we can navigate within the same frame without having to reload the whole page. This makes the user experience smoother and more efficient.

Let’s add our Turbo Frame to the partial:

<!-- app/views/photos/_photo.html.erb -->

<%= turbo_frame_tag :photo do %>
<!-- IMAGE TITLE -->
...

<!-- IMAGE -->
...

<!-- NAV CONTROLS -->
...
<% end %>

This way, when you click on the navigation controls, the response to the request will be rendered in the frame.

Implementing the Navigation

Navigation will be super easy. We just need to add two new routes and their corresponding actions to our slideshow_controller. These actions will take the ID of the current photo being displayed and show the next or previous one based on that ID:

def next
# If it's the last photo, it assigns the first one.
@photo = Photo.find_by('id > ?', params[:id].to_i) || Photo.first

render @photo # Renders _photo partial
end

def previous
# If it's the first photo, it assigns the last one
@photo = Photo.order(id: :desc).find_by('id < ?', params[:id].to_i) || Photo.last

render @photo # Renders _photo partial
end

Let’s add the routes:

resources :slideshow, only: %i[index] do
member do
post 'next'
post 'previous'
end
end

Finally, let’s update the controls to point to the new routes:

<%= button_to previous_slideshow_path(photo), class: 'absolute left-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none' do %>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
...
</span>
<% end %>

<%= button_to next_slideshow_path(photo), class: 'absolute right-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none' do %>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
...
</span>
<% end %>

Great! With these changes, you can now view your slideshow and navigate between photos seamlessly:

Synchronizing Photos Across Multiple Windows

Now, let’s make things a bit more complicated and have the slideshow sync across multiple windows. Basically, one person controls the slideshow and everyone else automatically sees the same image.

With our current solution, if we open a new incognito window next to the one we’re using, they each have their own navigation. Changing slides in one window doesn’t affect the other.

Independent navigation

Hiding Navigation Controls

The first thing we’ll do is make sure that only one user can see the controls. To do this, we’ll use the Devise gem we added earlier. The only user who should see the controls is the one who is logged in. Let’s add the following condition in the view, and to make our code neater, let’s move our controls to a new partial.

<!-- app/views/photos/_photo.html.erb -->

<%= turbo_frame_tag :photo do %>
<!-- IMAGE TITLE -->
...

<!-- IMAGE -->
...

<!-- NAV CONTROLS -->
<!-- Devise provides us with the method user_signed_in? to check if our user is logged in -->
<%= render 'photos/controls', photo: photo if user_signed_in? %>
<% end %>
<!-- app/views/photos/_controls.html.erb -->
<%= button_to previous_slideshow_path(photo), class: 'absolute left-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none' do %>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
...
</span>
<% end %>

<%= button_to next_slideshow_path(photo), class: 'absolute right-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none' do %>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
...
</span>
<% end %>

Additionally, let’s add the following to app/views/layouts/application.html.erb to display the logged-in user:

<%= content_tag :p, class: 'text-sm' do %>
<% if user_signed_in? %>
You are the Presenter
<%= link_to 'Sign out', destroy_user_session_path, data: { turbo_method: :delete }, class: 'underline decoration-sky-500' %>
<% else %>
You are a viewer
<% end %>
<% end %>

Now, if we log in on the left window (which can be done through localhost:3000/users/sign_in thanks to devise), that will be the user who sees the controls:

Hinding nav controls if user is not logged in

Synchronizing Navigation

So, what we’re going to do is sync both windows. Basically, when the user on the left clicks the next or previous button, the photo on the right will change automatically.
To make this happen, we’ll use Turbo Streams.

According to the official documentation, Turbo Streams work like this:

Turbo Streams deliver page changes as fragments of HTML wrapped in self-executing <turbo-stream>elements. Each stream element specifies an action together with a target ID to declare what should happen to the HTML inside it. These elements are delivered by the server over a WebSocket, SSE or other transport to bring the application alive with updates made by other users or processes

Basically, we can update a part of our pages directly from our controller using WebSockets. Plus, we can apply these changes to multiple sessions using broadcasts.

First, we’ll replace the Turbo Frame with a div since we don’t need it anymore. We’ll keep the id as "photo" because we'll use it later to replace the content with our Turbo Stream:

<!-- app/views/photos/_photo.html.erb -->

<%= content_tag :div, id: :photo do %>
<!-- IMAGE TITLE -->
...

<!-- IMAGE -->
...

<!-- NAV CONTROLS -->
...
<% end %>

In addition, we’ll need to specify a channel to listen for the change received from our controller. We’ll do this in the index.html.erb through the turbo_stream_from method:

<!-- app/views/slideshow/index.html.erb -->
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<%= turbo_stream_from(:photos) %>

<%= render 'photos/photo' %>
</div>
</div>

In other words, all actions sent via Turbo Stream on the “photos” channel will be applied.

Next, we need to update our controller actions to let everyone know about the change. What we want to do is replace the element with the ID “photo” with the photos/photos partial.

def next
# If it's the last photo, it assigns the first one
@photo = Photo.find_by('id > ?', params[:id].to_i) || Photo.first

Turbo::StreamsChannel.broadcast_replace_to(
:photos, # Channel through which we broadcast the change.
target: 'photo', # ID of the element we want to replace.
partial: 'photos/photo',
locals: { photo: @photo }
)
end

def previous
# If it's the first photo, it assigns the last one
@photo = Photo.order(id: :desc).find_by('id < ?', params[:id].to_i) || Photo.last
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'photo',
partial: 'photos/photo',
locals: { photo: @photo }
)
end

Just as there is broadcast_replace_to, you can do the same with broadcast_remove, broadcast_append, and broadcast_prepend.

If we press Next or Previous in our slideshow, we will encounter the following error:

This happens because the parts used for Turbo Streaming are rendered by ApplicationRenderer instead of within the context of our request. In this case, Devise is trying to access our logged-in user, causing problems.

To fix this, we could move the _controls.html.erb part into index.html.erb to make sure that the use of the user_signed_in? method is not within the streaming.

It would look like this:

<!-- app/views/photos/_photo.html.erb -->
<%= content_tag :div, id: :photo do %>
<!-- IMAGE TITLE -->
...

<!-- IMAGEN -->
...

<!-- 🧹 We removed the invocation of the partial from here 🧹 -->
<% end %>
<!-- app/views/slideshow/index.html.erb -->
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<%= turbo_stream_from(:photos) %>

<%= render @photo %>

<!-- ✅ We moved here the invocation of the partial -->
<%= render 'photos/controls', photo: @photo if user_signed_in? %>
</div>
</div>

With this change, we‘ll make sure that the photos are updated accordingly. The current issue is that the navigation buttons are not being updated, and they consistently point to the same photo. It used to work seamlessly because they were part of the same partial template.

To fix this, we need to add another stream to our controller:

class SlideshowController < ApplicationController
def index
@photo = Photo.first
end

def next
@photo = Photo.find_by('id > ?', params[:id].to_i) || Photo.first
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'photo',
partial: 'photos/photo',
locals: { photo: @photo }
)
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'controls',
partial: 'photos/controls',
locals: { photo: @photo }
)
end

def previous
@photo = Photo.order(id: :desc).find_by('id < ?', params[:id].to_i) || Photo.last
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'photo',
partial: 'photos/photo',
locals: { photo: @photo }
)
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'controls',
partial: 'photos/controls',
locals: { photo: @photo }
)
end
end

Great! With these changes, our slideshow is synchronized between the windows.

It´s working!

Finally, we could refactor our controller and move the streaming logic to its own view. To do this, let’s extract the logic for broadcasting to a file named app/views/slideshow/photo.turbo_stream.erb:

# app/views/slideshow/photo.turbo_stream.erb
<%
Turbo::StreamsChannel.broadcast_replace_to(:photos, target: 'photo', partial: 'photos/photo', locals: { photo: @photo })
Turbo::StreamsChannel.broadcast_replace_to(:photos, target: 'controls', partial: 'photos/controls', locals: { photo: @photo })
%>

And let’s modify our controller as follows:

class SlideshowController < ApplicationController
def index
@photo = Photo.first
end

def next
@photo = Photo.find_by('id > ?', params[:id].to_i) || Photo.first
render :photo
end

def previous
@photo = Photo.order(id: :desc).find_by('id < ?', params[:id].to_i) || Photo.last
render :photo
end
end

As Turbo has captured our link, it will invoke the view at app/views/slideshow/photo.turbo_stream.erb, so everything will continue to work.

I hope you found this article useful and I would love to hear about your experience.

If you liked this article, you may be interested in one of these:

Unagi offers software development services in Ruby and React. You can learn more about us on any of our channels.

--

--

Nicolás Galdámez
Unagi
Editor for

Co-fundador de @unagi. Me gusta el cine, la lectura, y la ensalada de frutas.