Hotwire Turbo: Drive, Frames and Streams for Ruby on Rail projects

Hotwire Turbo: Drive, Frames and Streams for Ruby on Rail projects

Rails Creator, DHH (David Heinemeier Hansson) has shown some adversity towards Javascript and TypeScript in particular over the recent years. He suggests that the struggle to learn another language, which ultimately distracts you from writing functional code, might be a disadvantage in the long run. Therefore, in his company Basecamp, he created a framework that leverages JavaScript internally, so you don’t have to. The goal is to focus solely on your Ruby backend code, your business logic, rather than complex configurations with JavaScript, especially from the SPA (Single Page Application) perspective.

I presented this at the May 2024 session of the Montreal.rb Meetup. Since some people don’t watch videos, or view slides, or they might prefer a written version, this is a summarized version of it. You can bookmark it and return if you forget any concept. However, this is connected to the demo RoR application that I prepared for the talk, download it and try it. I bet the concepts will be very clear once you try and execute the application.

I also created a small demo application connected to SQLite, so you can test it at your own pace to delve deeper into Turbo concepts. I will leave all the relevant links in the next section.

Interesting links

Why avoid using Javascript, my opinion as a current NodeJS developer

It’s true, as a frequent TypeScript developer, you’ll find that every day you have to learn how to organize or type different functions to write some business logic. And I would say that TypeScript is not an easy learning curve for a Junior Developer, especially starting at the build step.

Since TypeScript is not supported in the browser or the Node runtime, you have to learn how to configure your development environment, focusing on the build and watch steps to support TypeScript. There are many preconfigured projects out there, but still, it leaves no option for the beginning developer who may want to start trying TypeScript. And let’s not even start on teaching them about tuples, generics, etc.

So, we sacrifice development speed to add strong typing to Javascript. Don’t misunderstand me, I agree that in the long term it’s better to have strong typing, especially for large and nested JSON objects. But as a starting point, it can discourage beginners from continuing to learn about programming, writing functional code that addresses some real-world problems, and then in the future, they can become experts in their craft. They should avoid creating unnecessary problems for themselves.

Why Turbo?

Turbo is the solution for creating SPA experiences using mostly your Ruby knowledge. You can write applications that can replace many of the features that a SPA would have, such as fast page loading and changing specific parts of the DOM.

One advantage of modern versions of Ruby on Rails, the web framework for creating Ruby applications, is that it comes with Turbo installed by default.

Turbo Drive: Preloading the pages

One of the key factors in choosing a Single Page Application (SPA) is the speed at which each page loads. After the first request is sent to the server, it returns all the JavaScript needed to run in your browser. This is where Turbo Drive comes into play. It helps preload any page from a link when you want to visit it and also keeps a historical track of it.

You can, of course, disable this behavior for specific links, which is useful if one of those links is really expensive to load. Otherwise, since it’s loaded asynchronously, it will give your customers the experience that your app has a fast navigation.

Reload styles or JavaScript assets when they have changed

Now, since you are frequently making changes to your JavaScript code, you can instruct Turbo Drive to reload any JavaScript or CSS Style code when a change is made in any of them. Just be sure to attach the required HTML tags to it.

<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>

Progress loading bar in Turbo

Also, you can enable the loading bar again, like in previous versions of Rails. You only need to make some modifications to your code.

Since the progress bar will appear on pages that take more than 500 ms to load, we can set this minimum to 0. It will then always be displayed.

Let’s use our main JS file to show this feature.

application.js
import { Turbo } from "@hotwired/turbo-rails";
// Reduce delay for progress bar from 500ms to 0ms
Turbo.setProgressBarDelay(0);

You can also modify the color of the progress bar. Simply update the .turbo-progress-bar class.

.turbo-progress-bar {
height: 5px;
background-color: red;
}

Use from JavaScript

You can also use Turbo Drive functions and properties from JavaScript. Here is an example.

import { Turbo } from "@hotwired/turbo-rails";
// Moving forward
Turbo.visit(location);
Turbo.visit(location, { action: "navigate" });
// Replacing top of history
Turbo.visit(location, { action: "replace" });
// Restore (Reserved for internal use, DON'T USE)
Turbo.visit(location, { action: "restore" });

Use from HTML

And here are a couple of examples as HTML code. ERB compatible.

<!-- Moving forward -->
<a href="/edit">Edit</a>
<!-- Replacing top of history -->
<a href="/edit" data-turbo-action="replace">Edit</a>

Morphing

We can specify how the page refreshes and updates the content using the following code.

<head>
...
<meta name="turbo-refresh-method" content="morph" />
</head>

Scroll preservation

Also, within your navigation, you can preserve the scroll position. One example would be going back to a long article. As a user, I would like to return to the same position where I was before visiting a link in the post.

<head>
...
<meta name="turbo-refresh-scroll" content="preserve" />
</head>

Exclude content from morphing

This is a really interesting feature if you want to keep sections excluded from the morphing behavior. Let’s say, for example, to keep alert messages or information that persists between pages.

<div data-turbo-permanent>...</div>

Ruby gem’s helper methods

Also, when we use the Turbo Drive gem, we can take advantage of the helpers and functions that the gem provides. Here is a sample list of some of the most commonly used methods.

<% turbo_exempts_page_from_cache %>
<% turbo_exempts_page_from_cache_tag %>
<% turbo_page_requires_reload %>
<% turbo_page_requires_reload_tag %>
<% turbo_refresh_method_tag(method = :replace) %>
<% turbo_refresh_scroll_tag(scroll = :reset) %>
<% turbo_refreshes_with(method: :replace, scroll: :reset) %>

Testing in Turbo Drive

For testing your code, you can use the same approach as you do for testing your regular Ruby on Rails applications.

For most of the CRUD apps out there, you need to be sure that your application is executing the 4 main actions from it (Create, Read, Update, and Delete).

Check the following code, there is nothing new about Turbo Drive.

jobs_controller_test.rb
class JobsControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get jobs_path
assert_response :success
end
test "should create job" do
job_count_first = Job.count
post jobs_path, params: { job: { name: 'New Job', status: :active, tag_id: tags(:tag_ruby).id } }
job_count_last = Job.count
assert_equal job_count_first + 1, job_count_last
assert_redirected_to job_path(Job.last)
end
# ... other tests
end

Turbo Frames: load other pages within the same page, component’s behavior

Working with Turbo Frames is similar to dealing with components in a Frontend framework.

You can define your partials in another view and then use a parent view to load them.

Once a change is made, you can trigger a refresh or a reload of a target frame.

Example of a Turbo Frame

A Turbo Frame only declared with HTML will look like the next code.

<turbo-frame id="my-refreshing-frame" refresh="morph"> ... </turbo-frame>

Turbo Frames properties

Turbo Frames allow us to modify their behavior with the following properties:

  • src: A URL or path that controls the navigation of the element.
  • loading: This has two values, eager and lazy. loading="eager" will immediately load the frame, while loading="lazy" will load the frame when it becomes visible.
  • busy: A boolean attribute that indicates if the frame is currently loading. This is managed by Turbo.
  • disabled: This is used for disabling frame navigation.
  • complete: A boolean attribute that indicates if the frame has finished loading. This is managed by Turbo.
  • autoscroll: A boolean attribute that indicates if the frame should scroll to the top after loading. This is managed by Turbo.

Also, since Turbo can be used from JavaScript, remember that you have access to all these properties there as well.

  • FrameElement.src
  • FrameElement.disabled
  • FrameElement.loading
  • FrameElement.loaded
  • FrameElement.complete
  • FrameElement.autoscroll
  • FrameElement.isActive
  • FrameElement.isPreview

Gem usage

If you are using the Gem, you can also use some Turbo Frame helpers as well.

<%= turbo_frame_tag "tray", src: tray_path(tray) %>
# => <turbo-frame id="tray" src="http://example.com/trays/1"></turbo-frame>
<%= turbo_frame_tag tray, src: tray_path(tray) %>
# => <turbo-frame id="tray_1" src="http://example.com/trays/1"></turbo-frame>
<%= turbo_frame_tag "tray", src: tray_path(tray), target: "_top" %>
# => <turbo-frame id="tray" target="_top" src="http://example.com/trays/1"></turbo-frame>
<%= turbo_frame_tag "tray", target: "other_tray" %>
# => <turbo-frame id="tray" target="other_tray"></turbo-frame>
<%= turbo_frame_tag "tray", src: tray_path(tray), loading: "lazy" %>
# => <turbo-frame id="tray" src="http://example.com/trays/1" loading="lazy"></turbo-frame>
<%= turbo_frame_tag "tray" do %>
<div>My tray frame!</div>
<% end %>
# => <turbo-frame id="tray"><div>My tray frame!</div></turbo-frame>
<%= turbo_frame_tag [user_id, "tray"], src: tray_path(tray) %>
# => <turbo-frame id="1_tray" src="http://example.com/trays/1"></turbo-frame>

Two Turbo Frames in the same page

We can use multiple Turbo Frames within the same page. I will use the HTML version tag to generate two frames on the same view. The goals are:

  • To display a <turbo-frame/> form to store tags, with the view pulled from a partial.
  • To display the list of tags beside it, which will be refreshed when the form is submitted.
  • To avoid reloading the page.
app/views/tag_frame/index.html.erb
<turbo-frame id="<%= TagFrameController::TAG_FRAME_FORM_ID %>" src="<%= new_tag_frame_path(@tag) %>">
</turbo-frame>
<turbo-frame id="<%= TagFrameController::TAG_FRAME_ID %>">
<!-- code to display the table -->
</turbo-frame>

The first <turbo-frame> indicates a src property, this is the view/partial from which we are pulling the content. In this case, it’s the route that renders the new view. Here is the content.

app/views/tag_frame/new.html.erb
<turbo-frame id="<%= TagFrameController::TAG_FRAME_FORM_ID %>">
<%= form_with model: @tag, url: tag_frame_index_path, data: { turbo_frame: TagFrameController::TAG_FRAME_ID } do |form| %>
<div class="mb-2">
<%= form.label :name %>
<%= form.text_field :name %>
</div>
<%= form.submit "Create tag", class: "block mt-7 py-3 bg-black rounded text-white text-center w-full" %>
<% end %>
</turbo-frame>

As you might see, we have a data: { turbo_frame: TagFrameController::TAG_FRAME_ID } property in the form. This means, once we process the form, it will render the content in that frame as a target. So, our backend will look like this.

class TagFrameController < ApplicationController
TAG_FRAME_ID = "tags-frame"
TAG_FRAME_FORM_ID = "tags-frame-form"
def index
@tags = Tag.all
@tag = Tag.new
end
def create
@tag = Tag.new(tag_params)
if @tag.save
redirect_to tag_frame_index_path
else
redirect_to new_tag_frame_path
end
end
def new
@tag = Tag.new
end
private
def tag_params
params.require(:tag).permit(:name)
end
end

More interesting things about Turbo Frames

I didn’t cover this in my presentation, but you can also do additional things with Turbo Frames, including interesting features like:

  • Lazy-loading frames
  • Caching
  • Cross-Site Request Forgery (CSRF)
  • Navigation from a frame

Testing in Turbo Frames

Tests work the same as regular tests for a Rails application. But for this case, check that we are aiming the create and list actions towards the same URL, since both actions will be executed from that route.

class TagFrameControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get tag_frame_index_path
assert_response :success
end
test "should create tag" do
post tag_frame_index_path, params: { tag: { name: 'New Tag' } }
assert_redirected_to tag_frame_index_path
end
# ... other tests
end

Turbo Streams: load only the modified data

In Turbo Frame, you load large chunks of views. In Turbo Streams, you render only the data you really need, specifying its behavior, such as whether to append, prepend, replace, etc.

This feature can optionally be combined with Websockets. Thus, any new information stored in your database will appear in real time to all your users!

However, the Turbo Stream documentation states the following:

It’s good practice to start your interaction design without Turbo Streams. Make the entire application work as it would if Turbo Streams were not available, then layer them on as a level-up. This means you won’t come to rely on the updates for flows that need to work in native applications or elsewhere without them.

Also, if you use this in Ruby on Rails, you can leverage Action Cable and Active Jobs to render the content as needed. For this example, I am also using the gem for a Ruby on Rails project.

Additional behavior on rendering

If you want to trigger side effects when you perform a Turbo Stream rendering, you might want to use Stimulus controllers. For that, you will need to work with JavaScript files.

Actions

With Turbo Stream, we have eight actions (behaviors) available. These specify how your content will be added to the content already rendered.

Those actions are:

  • append: Appends the given content to the end of the element specified by the target.
  • prepend: Adds the given content to the beginning of the element specified by the target.
  • replace: Replaces the entire content of the target element with the given content.
  • update: Replaces the target element with the given content.
  • remove: Removes the target element from the DOM.
  • before: Inserts the given content immediately before the target element.
  • after: Inserts the given content immediately after the target element.
  • morph: Replaces content using the morph technique in the target element.
  • refresh: Refreshes the content within the target element.

Shape of a Turbo Stream Render

<turbo-stream action="append" target="dom_id">
<template> Content to append to container designated with the dom_id. </template>
</turbo-stream>

Now, to allow Turbo Streams to insert that content into your HTML, you must pre-render an element with a target id, such as dom_id, for example.

This means, if you want to replace or morph a specific element, your HTML should look like this:

<tr id="<%= dom_id job_application %>">
...
</tr>

But if we want to prepend, append, or perform any similar action to the parent element, we need to add a unique ID.

<tbody id="<%= JobApplicationController::JOB_APPLICATION_STREAM_ID %>">
...
</tbody>

Then, if you want to create a new record and add it to your current table, the controller code will look like this:

job_application_controller.rb
class JobApplicationController < ApplicationController
#...
def create
@job_application = Application.new(job_application_params)
@job_application.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
# Rendering the list
turbo_stream.prepend(JOB_APPLICATION_STREAM_ID, partial: 'job_application/job_application', locals: { job_application: @job_application }),
# It's an array so you can render another turbo stream here
]
end
end
end
#...
private
def job_application_params
params.require(:application).permit(:job_id, :name, :cover_letter)
end
end

That’s why you need a parent ID. Let’s now see an example where we need to remove a specific element from the table.

job_application_controller.rb
class JobApplicationController < ApplicationController
#...
def destroy
@job_application = Application.find(params[:id])
@job_application.destroy
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.remove(@job_application)
end
end
end
end

Using the Ruby Gem gives us many advantages and provides well battle-tested methods to interact with Turbo.

Testing for Turbo Streams

We need to be very specific about the type of response that we expect from the controller. According to the case, you might need to specify to your test suite that you are expecting a stream object.

For such testing behavior, add as: :turbo_stream to the end of your route call. See the following example.

job_application_controller_test.rb
class JobApplicationControllerTest < ActionDispatch::IntegrationTest
test "should create job application with turbo stream" do
job_application_count = Application.count
post job_application_index_path, params: { application: { job_id: jobs(:job_javascript_sr_developer).id, name: 'John Doe', cover_letter: 'I am an excellent developer' } }, as: :turbo_stream
job_application_count_after = Application.count
assert_equal job_application_count + 1, job_application_count_after
assert_response :success
end
# ... other tests
end

Conclusions

Remember that most of the content of this post has been condensed to fit into a one-hour slide presentation. Of course, I won’t cover every aspect, but the goal is to show you the basics so you can start your own adventure practicing and learning Turbo, and use it in your next project.

Use Turbo when you have the chance, especially if you work with a lot of talented Ruby Developers. The shift in context and knowledge will be minimized and the time will be spent on creating more features for the users rather than figuring out how to correctly transpile your TypeScript code and which tool you can use.

Would I recommend developers to stop learning about JavaScript because we have tools like Turbo? Not at all. Rather than that, I encourage them to continue learning, but once they’ve created something, whether it’s at work or on their own projects. JavaScript is widely used around the world and probably by any job that you will look for. They require some basic to intermediate JavaScript knowledge. Also, remember that if you need to do extra stuff with Turbo, sooner or later you will have to work with Stimulus Controllers, which are written in JavaScript.

My posts are not AI generated, they might be only AI corrected. The first draft is always my creation

Tags

Author

Written by Helmer Davila

In other languages

Emmène ton appli au niveau supérieur, sans JavaScript

Hotwire Turbo: Drive, Frames et Streams pour des projets en Ruby on Rails

Llevando tu app al siguiente nivel, sin JavaScript

Hotwire Turbo: Drive, Frames y Streams para proyectos en Ruby on Rails

Related posts

Rails 6 + MySQL + PHPMyAdmin

Rails 6: Running on Docker with PHPMyAdmin

Using Docker compose to create a Dockerized environment

Rails 7 with Ruby 3, MySQL 8 and Redis in Docker Alpine