Turbo 8 morphing refreshes on Rails

Turbo 8 was released in February of 2024, to much excitement in the Rails world, and the broader HTML-over-the-wire community. At the top of the list of new features was the combination of tooling that added support for morphing page refreshes and scroll position preservation, powered by idiomorph and the ability to broadcast page refreshes with a new Turbo Stream action.

This combination offers developers a “happier path” to partial page updates. Turbo Stream DOM manipulation actions (like prepend and replace) work well, but they require maintaining separate code paths and have limitations when broadcasting data in applications that need to care about authorization and access levels inside of a team or company setting.

Morphing refreshes are a powerful new tool in the Turbo (and turbo-rails) toolbox but they require a bit of a mental shift to understand and they are not without their drawbacks. Today we are going to explore Turbo 8’s new refresh feature in the context of a Rails application, learning how to use page refreshes, how to use broadcasted refreshes to push updates to all interested users at once, and dig into the pros and cons of refreshes compared to Turbo Stream actions that manipulate the DOM.

Before we begin, this article assumes you have written Rails code before and that you have a basic level of understanding with Turbo and how to use it within a Rails application. If you are not a Rails developer, you may have a hard time following the code at some points.

Let’s dive in.

Application Setup

We are going to build a simple blog + post data model to try out page refreshing, starting from a new Rails 7 application with Tailwind CSS. Tailwind is not required for any part of this article, but we will add it to have nicer looking pages as we work.

If you prefer to skip past the setup and go right to refreshing, you can clone this repository and skip ahead to the next section. We will move rapidly through the setup code, which should all be familiar if you are comfortaable working in Turbo-powered Rails applications.

Get started from your terminal:

rails new refreshable-blog --css=tailwind
cd refreshable-blog
bin/setup

After creating the new application, generate the Blog and Post models:

bin/rails g scaffold Blog name:string
bin/rails g model Post name:string blog:references
bin/rails db:migrate

And then generate a controller for Posts and the views we will need to create and render posts from a Blog show page. From your terminal:

bin/rails g controller Posts
touch app/views/posts/_form.html.erb app/views/posts/create.turbo_stream.erb app/views/posts/_post.html.erb

Posts belong to a Blog, so update the Blog model to reflect that association:

class Blog < ApplicationRecord
  has_many :posts
end

Posts are only visible from their parent Blog, so we can update config/routes.rb to define the proper nested routes for our application:

Rails.application.routes.draw do
  resources :blogs do
    resources :posts
  end
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
  # Can be used by load balancers and uptime monitors to verify that the app is live.
  get "up" => "rails/health#show", as: :rails_health_check

  # Defines the root path route ("/")
  root "blogs#index"
end

Now we’ll build our views, starting with the posts form at app/views/posts/_form.html.erb:

<%= form_with(id: "post_form", model: [blog, post], class: "contents") do |form| %>
  <% if post.errors.any? %>
    <div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
        <% post.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="my-5">
    <%= form.label :name %>
    <%= form.text_field :name, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
  </div>

  <div class="inline">
    <%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
  </div>
<% end %>

Just regular old ERB and Tailwind here, with an id on the form to identify it in the DOM, which we need for targeting a Turbo stream replace call when the form is submitted. Next up, app/views/posts/_post.html.erb:

<div>
  <%= post.name %>
</div>

Complicated stuff there.

Next fill in our Turbo Stream erb template at app/views/posts/create.turbo-stream.erb:

<%= turbo_stream.replace('post_form', partial: 'posts/form', locals: { post: Post.new, blog: @post.blog }) %>
<%= turbo_stream.append('posts', partial: 'posts/post', locals: { post: @post}) %>

This view will be rendered when a new post is created. In it, the two Turbo Stream actions replace the post_form form to clear whatever data the user entered on the form and then append the new post to the list of posts.

Next, we want to render posts and the new post form on the blog show page. Let’s head there next and update it:

<div class="mx-auto md:w-2/3 w-full flex">
  <div class="mx-auto">
    <% if notice.present? %>
      <p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
    <% end %>

    <%= render @blog %>

    <%= link_to "Back to blogs", blogs_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
  </div>
</div>
<div class="mx-auto md:w-2/3 w-full w-full">
  <h2>Blog posts</h2>
  <div id="posts">
    <% @blog.posts.each do |post| %>
      <%= render "posts/post", post: post %>
    <% end %>
  </div>
  <%= render "posts/form", post: @post.presence || Post.new, blog: @blog %>
</div>

More standard ERB here, with some Tailwind classes provided by bin/rails scaffold. Before this will work, we need to implement the create action in our PostsController. Head to app/controller/posts_controller.rb and update it:

class PostsController < ApplicationController
  def create
    @post = Post.new(post_params)
    @post.blog_id = params[:blog_id]

    respond_to do |format|
      if @post.save
        format.turbo_stream
      else
        format.turbo_stream { render turbo_stream: turbo_stream.replace('post_form', partial: 'posts/form') }
      end
    end
  end

  private

  def post_params
    params.require(:post).permit(:name)
  end
end

When the create action runs, successful form submissions render a turbo_stream view (that’s the create.turbo_stream.erb file we created earlier). When errors are present, a turbo_stream.replace action runs to replace the form content with any relevant errors.

This implementation of creating and inserting new objects using Turbo and turbo-rails is a common way to leverage Turbo in a Rails application today and will be our starting point for exploring how Turbo refreshes can be leveraged to simplify our code without sacrificing user experience.

Before ending the setup section, head over to app/views/layouts/application.html.erb to adjust the base page layout:

<!DOCTYPE html>
<html>
  <head>
    <title>RefreshableBlog</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <main class="container mx-auto mt-28 px-5">
      <%= yield %>
    </main>
  </body>
</html>

Here we dropped a flex class from the main element so that the form shows up under the list of posts instead of beside the list of posts. Why that flex is there in the default Tailwind + Rails layout is anyone’s guess, but it is gone from our application now.

If all has gone well with setup, you should be able to start up your Rails server with bin/dev and visit http://localhost:3000. Create at least one blog, head to that blog’s show page, and add a few posts to see that inserting new posts with Turbo Streams works as expected and then move on to the next section where we will start adding Turbo refreshes to our application.

Refreshing posts

Our goal in this section is to replace the current method of inserting new posts on the blog show page with a Turbo-powered morphing refresh. This will require much less code than you might expect.

Start by updating the PostsController at app/controllers/posts_controller.rb:

class PostsController < ApplicationController
  def create
    @blog = Blog.find(params[:blog_id])
    @post = Post.new(post_params)
    @post.blog = @blog

    if @post.save
      redirect_to @blog
    else
      render 'blogs/show', status: :unprocessable_entity
    end
  end

  private

  def post_params
    params.require(:post).permit(:name)
  end
end

Here we redirect to the blog show page if the new post is created. If the post cannot be saved we render the blog show page again with a 422 status code. This is standard Rails, there is no new magic required in the controller to use page refreshing, we are redirecting and rendering as we have been for more than a decade.

If you go to a blog show page now and submit the post form, you will notice that everything works except that the redirect is a full, normal page turn, meaning you lose your scroll position. Any ephemeral on-page changes (like opening a dropdown menu or interacting with a form element) would also be lost. We have done a full page turn and redirected the browser to a new page, so that’s all expected behavior but the user experience of jumping to the top of the page when they submitted a form to add a new post is not great.

Let’s fix that by telling Turbo to using morphing for refreshes and to maintain scroll position when a page refresh occurs. Head to app/views/layouts/application.html.erb and update it:

<!DOCTYPE html>
<html>
  <head>
    <title>RefreshableBlog</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
    <%= turbo_refreshes_with(method: :morph, scroll: :preserve) %>
    <%= yield :head %>
  </head>

  <body>
    <main class="container mx-auto mt-28 px-5">
      <%= yield %>
    </main>
  </body>
</html>

Here we added two lines to the head:

<%= turbo_refreshes_with(method: :morph, scroll: :preserve) %>
<%= yield :head %>

This helper method adds the meta tags Turbo looks for to enable morphing and scroll preservation on a page. In the source, you will note that turbo_refreshes_with uses provide to add content to head, which will not be rendered until yield :head is called. If you have encountered issues using turbo_refreshes_with without the following yield call and gotten frustrated at the difference in this helper and the other meta tag helpers in turbo-rails which do not require a yield call after, the discussion on this (later reverted) PR may be interesting. You can also use turbo_refresh_method_tag and turbo_refresh_scroll_tag to set the refresh method and scroll handling meta tags individually, or just add the <meta> tags directly without using helper methods, if that’s your style.

You should also note that we added the meta tags into the application layout, which means they will be there on every page in our application. In a real application, you may find that setting these values on every page can have unintended consequences. If you are adopting morphing refreshes and the accompanying scroll preservation mechanic in an existing application, it is wise to add these meta tags into the head only on pages that require morphing and scroll preservation to avoid unexpected behavior on pages that should not attempt to morph content or preserve scroll position.

Refresh the blog show page, add a new post and see that submitting the form and following the redirect does not lose scroll position. That’s the all of the work that it takes to use morphing page refreshes with Turbo and Rails. There are no more steps, there is no more code to write. Magical.

Let’s pause here and talk through what’s happening. In particular, what triggers a morphing refresh before we move on to fancier use cases, including broadcasting refreshes to other users of the application.

Users of our application visit a blog show page, at a url like /blogs/12. On the page, we render a form to create a new post. When that form is submitted, two outcomes are possible:

  1. If the post is saved, we redirect to the blog show page again, so the original and the destination URL match.
  2. If the post is not saved, we render the blog show page again without redirecting anywhere, so the original and the destination URL match.

In both cases, the original URL and the destination URL match. This matching is the key that unlocks Turbo refreshes. A morphing refresh triggered by a normal response/request cycle will only occur when the user starts and ends on the same page (with the same URL). If we are navigating between different pages, at different URLs, Turbo Drive won’t attempt to morph anything. This makes sense, I think, but it is worth stating it. Turbo refreshes are refreshing the same content, with changes that are morphed in using idiomorph.

You can use morphing refreshes for pages like our blog show page that allow users to manage child content or otherwise make changes that a user would expect to keep them in the current context of the page. If you have a more traditional CRUD application with full new and edit pages, those pages will not benefit from refreshing.

For a thoughtful, more in-depth discussion of what page refreshes are and how they work, I found this piece by Jon Sully to be a valuable read.

So far, we have seen how we can use Turbo refreshes and morphing to replace Turbo Streams rendered from a controller without sacrificing user experience. Our implementation of refreshes so far is still within the normal request/response cycle for a single user. The user submits a form, and we send back an HTML payload that Turbo processes with idiomorph. This is valuable, but we can unlock more power by broadcasting refresh actions to all users with Turbo Streams.

Let’s see how it works.

Broadcast refreshes

Broadcasting Turbo Stream actions via a WebSocket connection allows us to push real-time updates out to all interested users as data in our application changes. In our application, we want to broadcast changes to posts each time a post is created, updated, or deleted. Any user on the post’s blog show page should see those updates as they happen.

Start by updating the Post model at app/models/post.rb:

class Post < ApplicationRecord
  belongs_to :blog

  broadcasts_refreshes_to :blog
end

broadcasts_refreshes_to adds an after_commit callback that broadcasts a refresh Turbo Stream action. The refresh action in turn performs a replace visit to fetch updated content for the same URL from the server and update the page with the new content on each message recipient’s browser. Thanks to the magic of morphing, this all happens without the recipient losing their scroll position or the current state of the page — updated content just appears seamlessly.

To make this work, we have one more piece to add. Head to app/views/blogs/show.html.erb and update the posts section of the page like this:

<%= turbo_stream_from @blog %>
<div>
  <% @blog.posts.each do |post| %>
    <%= render "posts/post", post: post %>
  <% end %>
</div>

Here we added the turbo_stream_from call, subscribing to a channel specific to the @blog we are viewing. This channel matches the channel we are broadcasting on in the model — the :blog symbol translates into a call to post.blog. Since we are viewing the show page for the blog that the post belongs to, post.blog equals @blog, and so our users will be subscribed to the right channel to see updates for the blog they are viewing.

It is important to understand that broadcasted refreshes do not care about what page the user is on. To use morphing refreshes in the normal request/response cycle, like we did in the last section, the original and destination URL need to match. When we are broadcasting refreshes to users, the Turbo Stream refresh action prompts Turbo to go fetch the user’s current URL again, as long as they are on a page that subscribes to the relevant stream channel, they will get updated content for that page. Again, this makes sense I believe, but it is important to understand that refresh Stream Actions are different than morphing page refreshes.

Let’s pause here and see that broadcasting works. Open up the same blog show page in two browsers. In one browser, create a new post. If all has gone well, the post will appear in the list of posts on the second browser without changing scroll position.

Neat. We have now seen how to use Turbo 8’s morphing refreshes in the context of a regular request/response cycle and using Turbo Stream broadcasts in turbo-rails. Broadcasted refreshes are a powerful tool, but they can have some surprising behavior. Let’s demonstrate one of the most common gotchas now, and discuss how to work around this behavior.

Head to app/controllers/posts_controller.rb and update the create action to add a sleep, to make it easier to see the problem:

def create
  @blog = Blog.find(params[:blog_id])
  @post = Post.new(post_params)
  @post.blog = @blog
  sleep 5
  if @post.save
    redirect_to @blog
  else
    render 'blogs/show', status: :unprocessable_entity
  end
end

The sleep here adds 5 seconds of wait time before a form submission processes, which gives us enough time to submit the form in one window and then switch over and start working on the form in the other window. Try it out now — submit the form in one window and then move over to the other window and start typing in the post name field in the other window.

This is not a great experience for your users! Saving scroll position is great, but if you wipe out a user’s work every time a broadcasted refresh runs, they are not going to be happy.

Why does this happen? Idiomorph is smart and only updates the parts of the page that are different when a refresh runs; however, form inputs are tricky. When the new page content comes back, the form will always be a clean, default version of the form because that is what the server knows about; the server does not know that the user is currently halfway through writing a novel in a form input. Idiomorph sees the clean version of the form, sees that the current version of the page is different, and replaces the old, active version of the form with the new, empty version. Woof.

There are ways to fix this behavior, most commonly reaching for the data-turbo-permanent attribute. This attribute skips morphing of the element and any child elements. In our particular use case, since we are relying on Turbo refreshing to update the browser of the user that submitted the form and broadcasting those changes as refreshes to every other user, we need more than data-turbo-permanent to make it work. Adding data-turbo-permanent to a wrapped div around the form inputs will fix the issue of lost focus that we saw in the above demonstration. However, that change alone will also cause the form not to reset for the user who submitted the form in the first place, requiring them to delete whatever they typed into the form or making it look like something broke, which is not an acceptable user experience.

A complete fix for this behavior in our use case could be both a data-turbo-permanent element and the introduction of a Stimulus controller to clear the form for the user that made the original submission.

A separate article will dive further into the details of handling forms in refreshes, but it is important to think about morphing and how it will affect users as you begin to build more complex user interfaces. Modals, drawers, drop down menus, forms, and third party JavaScript libraries that dynamically add elements to the DOM can have surprising behavior when combined with morphing refreshes.

That’s all of the code we are going to write today, nice work following along!

We will wrap up today by talking through some of the pros and cons of using morphing refreshes, and broadcasted refreshes in your Turbo-powered Rails applications. You can see the full code for today’s article on Github.

Pros and cons of Turbo Refreshes

From my perspective, the two biggest wins from Turbo morphing refreshes, and the accompanying broadcasted refresh in turbo-rails are:

  • Simpler happy paths in the code. With refreshes we do not need to worry about setting DOM ids (and hoping they aren’t removed or duplicated in the future), maintaining separate paths in our controllers, or creating and maintaining turbo_stream.erb view files. Write regular old Rails code and shove a couple of meta tags into the head on pages that should use morphing and you are good to go.
  • Broadcasted refreshes solve the session context problem that is one of the most common stumbling blocks for developers learning to use Turbo Stream broadcasts. Because a refresh broadcast is a ping to tell the recipient browser to go fetch new content for the page, we can broadcast updates without worrying about exposing data or functions that should be hidden. While there were ways to work around the lack of session context in stream broadcasts that deliver rendered content in the original message, those workarounds are more complex and provide a worse user and developer experience than than the simple, clean implementation we get with broadcasted refreshes.

As we saw with the challenge of keeping form state intact, there are tradeoffs and challenges that come with morphing refreshes:

  • Form inputs and dynamic page content can be tricky to deal with, requiring more code and a carefully planned architecture to get the most benefit from refreshes. Needing to write extra code to support refreshes gets off of the happy path sold as a key benefit of morphing refreshes.
  • Refreshes are more resource heavy than other Turbo Stream actions. In a standard Turbo Stream action, the server renders only the content that needs to be updated and nothing more, and much less work is required by the user’s browser to update the DOM. Rendering the entire page and then asking the browser to diff the entire page and update it in real-time is not free for you or for your users, especially if they are on lower end devices without the processing power that our development machines have. While we may not need to care about speed and costs on small personal projects, if we are building real applications, we should consider what the cost is for us (rendering lots of full pages when 95% of the content is identical) and to our users (who suddenly have more work for their browsers to do) for the cleaner code paths that morphing refreshes provide.
  • There are also speed differences here with broadcasted refreshes. A broadcasted refresh requires the original WebSocket message to be sent to tell the recipients browser to fetch new content and then a round trip to the server to fetch that content and replace the current page. With a traditional Turbo Stream broadcast, the updated DOM content is contained in the original WebSocket message. Cutting out that round trip means that a Turbo Stream append broadcast will get updated content to the user faster than a Turbo Stream refresh broadcast. In many applications this speed different will not matter. The user is getting updates in the background and does not know an update is coming, but for some use cases refreshes will feel slower than other broadcast actions.

Turbo 8’s morphing refreshes are an exciting new tool in our toolbox, and as they mature and the community coalesces around best practices and expands the Turbo feature set around morphing, we can expect to see real value in production applications from adopting refreshes.

As always with shiny new developer tools, I encourage you to think about your use case and what value refreshes add to your application. For many existing applications that rely on Turbo Streams, the incremental value added by replacing all of that code with refreshes is not likely to be worth the effort; however, you may find that in new applications, or as you add new features to your existing application, relying on morphing refreshes lets you move faster without sacrificing the single page application feel that Turbo Streams provide.

For further reading on Turbo 8’s changes, the motivation behind the changes, and a deeper dive into how refreshes and morphing work, you may find these resources valuable:

That’s all for this article, thank you for reading! Turbo morphing and refreshing are very new and some functionality is still in flux. If I have missed something or you see a better approach, reach out and share your thoughts.

Hotwiring Rails newsletter.

Enter your email to sign up for updates on the new version of Hotwiring Rails, coming in spring 2024, and a once-monthly newsletter from me on the latest in Rails, Hotwire, and other interesting things.

Powered by Buttondown.