In a previous post I wrote about using Rails and Stimulus to create a SPA like experience with page navigation. Since then, the Basecamp team has released Hotwire to allow for similar functionality but with little-to-no Javascript.

We’ll take a look at updating the experience from the previous post to use Turbo Frames to provide inter-page navigation. Later, we’ll sprinkle in some Stimulus to provide some visual feedback to the user with loading states.

To recap, here is what we’ll be building.

Final product

First Steps

With Hotwire/Turbo, we have a concept of a Turbo Frame. A Turbo Frame is similar to frames of old, but can be thought of as a tool to break-down a page into separate areas of content. In our case, we’ll have two frames - one for the list of movies, and another for where we display the details about the selected movie.

Thinking in frames takes some getting used to, but it can be powerful once it clicks. The documentation has some good examples, but it still took a bit for it to click with me. In short, if you’re navigating within a frame, then if the response coming back also has that same frame id within it, Turbo will replace it for you automatically. If we don’t want that, we can set the turbo frame target, either to “_top”, or to a specific turbo frame elsewhere on the page.

Let’s see what our page looks like if we break it down into frames - one for the index, another for the content.

<div class="movies">
  <%= turbo_frame_tag :index, class: "movie-cards" do %>
    <% @movies.each do |movie| %>
      <%= link_to movie_path(movie[:slug], class: "movie-card" do %>
        <%= image_tag(movie[:thumb]) %>
        <div class="movie-item-detail text-center">
          <%= movie[:title] %>
        </div>
      <% end %>
    <% end %>
  <% end %>
  
  <%= turbo_frame_tag :details, class: "movie-detail" do %>
    Select a movie for more details...
  <% end %>
</div>

As a refresher, let’s see what our old “show” view looked like. Pretty barebones, just some details about the movie.

<p>
  Title: <%= @movie[:title] %>
</p>

<p>
  Summary: <%= @movie[:summary] %>
</p>

<p>
  Director: <%= @movie[:director] %>
</p>

<p>
  Length: <%= @movie[:length] %>
</p>

<p>
  Genre: <%= @movie[:genre] %>
</p>

<p>
  Year: <%= @movie[:year] %>
</p>

This setup will get us our frames. If we were to click on one of the movie cards, we’d see our index disappear and an error in the console.

Response has no matching <turbo-frame id="index"> element

As mentioned above, since our link was within a turbo-frame element, Turbo is expecting the html response to contain a matching turbo-frame element, and Turbo will swap out the content for us. Cool, let’s just wrap the show view in a turbo-frame element with an id of “index”, right? That won’t quite work because then we’d be replacing the index.

Since we’re deviating from the default navigation behavior, we need to do two things to instruct Turbo how to handle our navigation.

First, we need to wrap our response in a turbo frame, but with an id of “details”. This should be straight forward, let’s update our show template and wrap the response in a turbo-frame.

<%= turbo_frame_tag :details do %>
  <p>
    Title: <%= @movie[:title] %>
  </p>
  
  ...
<% end %>

Second, we need to tell Turbo that when a user clicks on a link inside the index frame, to target the details frame. Let’s update our index template and add a data-turbo-frame attribute, and tell Turbo to update the details frame when navigation is completed.

<%= link_to movie_path(movie[:slug], class: "movie-card", "data-turbo-frame": "details" do %>
  <%= image_tag(movie[:thumb]) %>
  <div class="movie-item-detail text-center">
    <%= movie[:title] %>
  </div>
<% end %>

With these changes made, if we refresh the page and click on a movie card, we’ll see the details update. Very cool! We’re about 80% of the way towards a SPA like experience, and we haven’t even written a single line of Javascript/Typescript - not bad!

If we pay close attention, we’ll see that there is some visual feedback that’s missing. There’s nothing to indicate to the user that an action is taking place. If they’re on a slow connection, or the details takes awhile to load, they won’t know that anything happened as there is no loading state. Second, the index frame never gets updated to show which movie is selected.

In the SPA example in the previous post, we were able to address both concerns through Javascript and using loading states. Fortunately, we can still implement both of those with a simple Stimulus controller.

Stimulus For Visual Feedback

In the old previous post, we used Stimulus to implement our own click handlers for when a user clicked on a movie, and we used RxJs to handle fetching of the html content, and we updated the DOM ourselves. All of that is now taken care of by Turbo, but we can still use Stimulus to handle some of the visual feedback. Let’s first create our Stimulus controller and tackle the issue of loading states.

// We'll name this "navigation_controller", for lack of a better term right now.
export default class extends Controller {
  static targets = ['content', 'loading'];

  displayLoading(event) {
    this.loadingTarget.classList.remove('hidden');
    this.contentTarget.classList.add('hidden');
  }

  displayContent() {
    this.loadingTarget.classList.add('hidden');
    this.contentTarget.classList.remove('hidden');
  }
}

We’ve added two targets - the content of the item, and some loading content. When we want to display the loading state, we hide the content target and show the loading target, and do the opposite when we want to display the content.

How do we incorporate the controller into our template? Let’s first add our targets and use the controller.

<div class="movies" data-controller="navigation">
  <%= turbo_frame_tag :index, class: "movie-cards" do %>
    <!-- ... -->
  <% end %>
  
  <%= turbo_frame_tag :details, class: "movie-detail", "data-navigation-target": "content" do %>
    Select a movie for more details...
  <% end %>
  
  <div class="loading-indicator hidden"
       data-navigation-target="loading">
    <%= image_tag("loading.gif") %>
  </div>
</div>

With this update we’ve done two things. First, we added our loading spinner to the DOM, in an initial state of hidden, and we flagged our turbo-frame as the content target for our navigation controller.

If we reload and click around, though, we’ll see that nothing has happened. That shouldn’t be too surprising, because we haven’t told Stimulus how to actually invoke our two method for displaying the loading or content state.

Taking a look at the Turbo documentation, there are a few events that stand-out as something we might be able to hook into. The first is the turbo:before-fetch-request event, which is fired before Turbo issues a request to load the page. This sounds like it would be a good time to update the DOM to display a loading state. Let’s wire that up.

The documentation states that these events are fired on the document, and fortunately Stimulus provides an easy way to hook into those.


<div class="movies" data-controller="navigation"
     data-action="turbo:before-fetch-request@document->navigation#displayLoading">
    <!-- ... -->
</div>

What we’re doing now, is anytime Turbo begins a request, we trigger the displayLoading action on our Stimulus controller. If we were to reload the page now, we’d see the loading state display. However, we are not notified when the navigation is complete, so we don’t really know when to then show the content.

Turbo fires another event when the request is completed, the turbo:before-fetch-response. This is confusing, because it has the term “before” in it, but it basically means that the response for the content is available, which means the http request completed, so we can hide our loading state, and unhide our content. Let’s wire that up the same way we did our loading state.


<div class="movies" data-controller="navigation"
     data-action="
     turbo:before-fetch-request@document->navigation#displayLoading
     turbo:before-fetch-response@document->navigation#displayContent
">
    <!-- ... -->
</div>

If we were to refresh the page and click around (and set the Network speed to slow), we’d see a flash of the loading state, followed by the details of the movie.

Click on details on a slow connection and user will see the loading indicator

If we wanted to really spice things up, we can incorporate RxJs to better handle the loading state and not show the loading state unless the request takes a certain amount of time. Check out my previous post to see how to do that.

Selected Item

Now that we have a loading state implemented, we want to let the user know in the index frame which item in the detail frame is being used. Unfortunately, there doesn’t seem to be an easy way to do this using the before-fetch-request event, as we don’t know the specific item being clicked. However, there’s another event which might be even better for us: turbo:click.

The turbo:click event is fired on the element that was clicked to trigger navigation. Let’s update our template to use this instead:

<div class="movies" data-controller="navigation"
     data-action="turbo:before-fetch-response@document->navigation#displayContent"
>
  <%= turbo_frame_tag :index, class: "movie-cards" do %>
    <% @movies.each do |movie| %>
      <%= link_to movie_path(movie[:slug]),
        data: {
          turbo_frame: "details",
          action: "turbo:click->navigation#displayLoading",
          navigation_target: "link"
        },
        class: "movie-card" do %>
        <!-- ... -->
      <% end %>
    <% end %>
  <% end %>

We’ve done a few things here. We removed one of our document level listeners, added a turbo:click action handler on the actual link itself and also added a data-navigation-target to tell the Stimuls controller about our link elements. We can now update our Stimulus controller to handle the event and new target.

export default class extends Controller {
  static targets = ['content', 'loading', 'link'];

  displayLoading(event) {
    this.loadingTarget.classList.remove('hidden');
    this.contentTarget.classList.add('hidden');

    let value = event.detail.url;

    this.updateLinks(value);
  }

  displayContent() {
    // ... 
  }

  // Iterate all of our links, remove the selected class, but then add
  // if the link href matches the url we're navigating to.
  updateLinks(item) {
    this.linkTargets.forEach((link) => {
      link.classList.remove('selected');
      if (link.href === item) {
        link.classList.add('selected');
      }
    })
  }
}

When the turbo:click event is fired, the url that we’re navigating to is included as part of the CustomEvent detail. We can use that to then iterate through our list of movie links, and set the selected class on that particular item. With this in place, if we refresh the page and start navigating around we’ll see that the movie being displayed is now highlighted in the index.

Final product

With the Hotwire tools, such as Turbo and Stimulus, it’s really impressive how far we can get with such little Javascript. As DHH would say, “look at all the code I’m not writing”.

If you’d like to see the final code, please feel free to browse the mike1o1/hotwired-spa-experience repository.