Decompose Turbo

06.03.2024 - Andy Pfister

Turbo is an extensive library aimed to provide a SPA-like experience without having to write any JavaScript. Turbo is made of different components, which sort of belong together but can also be used independently. I do not use Turbo often; if I do, I usually have to reread the whole guide because I forgot what the different parts do. Consider this a very surface-level summary of the Turbo library. I do not go into Turbo Native or Strada since I do not have any experience working with them.

Turbo Drive

Turbo Drive is the spiritual successor to turbolinks and rails-ujs. Making a complete HTTP request can take time. Specific with WebSockets, when doing a full page reload, the users disconnect from eventual web socket connections and reconnect, which is costly and they might miss something.

Upon clicking a link in your application, Turbo Drive intercepts the call and requests the HTML from your server for the requested page. Then, Turbo Drive replaces the body of the current page with the received content, merges head tags, and adjusts the lang attribute of your HTML if necessary.

Turbo Drive also makes this operation appear like a regular page request. Users see a progress bar when changing pages, the browser history is updated, and the back button works.

Since Turbo Drive side-steps the “regular” HTTP lifecycle, you have Turbo Drive event equivalents for everything. For example, the DOMContentLoaded event, which fires after the browser renders the initial HTML, becomes turbo:load. This change is something to remember when you use libraries that rely on these events.

Turbo Frame

A Turbo allows to decompose your page into independent contexts.

First, when you click a link inside a Turbo Frame or submit a form, upon receiving the response from your server, Turbo will check for a turbo-frame element with the same ID in the response and will replace the content of your current Frame with the received one. If the received response does not contain a corresponding Turbo Frame, it will yield “Content missing”.

Second, you can set a src attribute on your Turbo Frames and leave their body empty. Once the page is rendered, the Turbo Frame will fetch the content under the given URL, with the same behavior described above.

Third, combine loading="lazy" with src. In that case, the Turbo Frame will only load its content once it is visible on the page.

Fourth: If you want to break out of the Turbo frame and instead change the entire page, add target="_top". When you try to navigate away, Turbo will move the whole page instead of just the current frame.

Fifth: Adding refresh="morph" activates the morphing behavior explained later in this blog post.

Turbo Streams

Turbo Streams are fragments of HTML that declare how parts of a page should be updated. Here is an example of a flash message that gets displayed:

<turbo-stream action="update" target="flash">
    <template>
        <div class='text-white px-6 py-4 border-0 rounded mb-4 bg-green-600 flex flex-row items-center justify-between'
            data-controller='notification'>
            <div>
                <span class='text-xl inline-block mr-5 align-middle'>
                    <i class="clock-icon" />
                </span>
                <span class='inline-block align-middle mr-8'>
                    <p class='text-bold'>Successfully updated element!</p>
                </span>
            </div>
            <div>
                <button class='bg-transparent text-2xl font-semibold leading-none outline-none focus:outline-none'
                    data-action='click-&gt;notification#remove'>
                    <i class="cross-icon" />
                </button>
            </div>
        </div>
    </template>
</turbo-stream>

A Turbo Stream element always needs an action and a target. The available actions are append, prepend, replace, update, remove, before, and after. The target is an ID on your page. The content of the Turbo Stream is what you want to insert on the page. The content of the Turbo stream is ignored if your “action” is delete.

Turbo Streams connect back to Turbo Drive: When submitting forms, you can send back a Turbo Stream response, even if the request is technically a regular HTML request. Please note that if you use the data-turbo-method attribute on any link, Turbo will create an invisible form next to the link, and clicking the “link” will invoke a form submission, therefore also applying the behavior described above.

If you want to receive a Turbo stream response for regular links, add data-turbo-stream=true.

Turbo Streams, but with WebSockets

The Turbo guide, at the state of writing, mentions in the very first paragraph that you should use streams with WebSockets or server-sent events. The idea is that the state on your server might change and you want to inform all users, who currently use your application, about this change.

The turbo-rails library allows you to define callbacks on your models when changes should be broadcasted. In its simplest form, this can look as follows:

class Child < ApplicationRecord
  belongs_to :parent
  broadcasts_to :parent
end

Let’s check what will happen here:

When a new child is created, it will render the template in app/views/children/_child.html.erb and stream an append action to all connections subscribed to the parent ActionCable stream. The target of said append action is children, the plural of the model name. Therefore, you need to ensure you have an element on your page with the ID children.

Let’s make a code example for this. Our parent view could look as follows:

<h1><%= @parent.name %></h1>

<%= turbo_stream_from @parent %>

<div id="children">
  <%= render @parent.children %>
</div>

The turbo_stream_from tag will render a unique HTML tag that will subscribe the page visitor to a stream for parent, also where we broadcast our changes about the children!

Continuing with views: This is how our child partial could look.

<div id="#{dom_id(child)}">
  <h2><%= child.name %></h2>
</div>

The dom_id helper generates an identifier consisting of the model name and its ID. It is vital for the second effect of broadcasts_to, which is the update.

When a child gets updated, it will also render the template in app/views/children/_child.html.erb and stream an replace action to the before-mentioned stream. The target of this stream will be the dom_id of the child.

The third and last effect of broadcasts_to is the delete action. Once a child gets destroyed, a stream with delete is sent, removing the element from the DOM. The target is, again, the dom_id of the child.

This is the variant with the most “Rails magic” involved. You can set up the callbacks, define which template to render, and even dismiss updates to customize the behavior.

Turbo, but with morphing

The prominent new feature in Turbo v8 is morphing. As you saw above, with Turbo Streams, you have to know how you need to update the DOM of your clients. But sometimes, setting up all these streams to reflect all effects of a change correctly in all parts of the DOM is too complicated.

With morphing, you have the current state of the DOM and receive a new version. The morphing library, idiomorph in the case of Turbo, analyses the differences between the two and gradually transforms the current DOM to match the requested new version.

Besides being faster than potentially replacing the entire body, as Turbo Drive usually does, it is preserving the state. For example, the user does not lose its current scroll position, or the Stimulus controller can remain connected since the element within the DOM does not get removed unless it is no longer there in the new version of the DOM.

Turbo can apply morphing in two cases.

First, you re-visit the same page again. For example, you embed a form to edit children on our parent page.

<h1><%= @parent.name %></h1>

<%= turbo_stream_from @parent %>

<%= form_with model: Child.new do |form| %>
  <%= form.text_field :name %>
  <%= form.hidden_field :parent_id, value: @parent.id %>
  <%= form.submit %>
<% end %>

<div id="children">
  <%= render @parent.children %>
</div>

Then, on the server, you tell Turbo to redirect to the parent “show” page.

class ChildrenController < ApplicationController
  def create
    # We assume everything goes fine, with no validations whatsoever
    child = Child.new(children_params)
    child.save

    redirect_to parent_path(child.parent), status: :see_other
  end
end

Turbo will recognize that the page URL does not change, requests an HTML of the entire parent page, and then morphs the current page to the new version.

Second, there is a new Turbo stream action called refresh, which I deliberately did not mention earlier in “Turbo Streams”. Instead of doing what I showed above with broadcasts_to :parent, you can define that refreshes should be streamed instead.

class Children < ApplicationRecord
  belongs_to :parent
  broadcasts_refreshes_to :parent
end

Once a client receives a refresh stream action, it will reload the current page, where Turbo intercepts again, fetches the new HTML and does the morphing as described earlier.

Morphing and scroll preservation in Turbo must be activated using two meta tags.

<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">

Summary

As mentioned initially, Turbo is a considerable library and got even larger with v8. Therefore, it is good to know what the different parts do to maybe only use some of them. For example, lazy-loading content on your page with Turbo Frames is a super convenient way to speed up the loading of pages.

As always, with those long, technical posts, if you note anything that needs to be added or have ideas to further improve it, please contact me at andy.pfister@simplificator.com.