Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

December 22, 2023

Last updated July 11, 2024

Digging into Turbo 8's Morphing Feature in Ruby on Rails

Inspired by Jorge Manrubia’s talk from Railsworld I wanted to try out morphing which is coming to Turbo 8 very soon.

On October 9th, 2023 Jorge published a blog post that was a precursor to the talk. I recommend giving it a read.

The gist of the blog post and talk is that Turbo frames and streams are useful but often cumbersome to integrate since they are highly focused containers of logic.

They won’t be going away but might be more of a special-use tool coming up with the introduction of morphing which could be a more convenient and useful “default” much like the standard full-page reloads of historical Rails apps.

Discovering the problem

The Basecamp team has been working on integrating a calendar into their HEY product. In building the new feature, they quickly spotted the constraints of turbo frames and streams. Having to broadcast and update many items on a given page is problematic and overly complex, so they looked for a better approach, one much closer to the default Rails full-page reload conventions.

What is morphing?

No, this doesn’t relate to Power Rangers, though one could wish!

Morphing is the process of merging one DOM into another without too many side effects. It’s not necessarily natural but the perception our eyes see makes it feel as such.

Morphing isn’t new, but it is to Turbo 8. The Basecamp team chose idiomorph as a library to help with the new features. It's a JavaScript library for morphing from one DOM tree to another.

The TL;DR;

Morphing provides smoother updates everywhere rather than selective updates like turbo streams and turbo frames.

Putting it to practice

Let’s make a quick blog to put these new features to work. I prefer esbuild to importmaps so I passed a flag to declare that. Feel free to skip that part.

rails new morphblog -j esbuild

Add Tailwind CSS

I’m going to scaffold our models coming up so to make the views pre-styled and save myself some time we’ll leverage the dedicated tailwindcss-rails gem.

bundle add tailwindcss-rails

Then run the installer

rails tailwindcss:install

The app is now accessible at localhost:5000

Install Turbo

Add the latest beta of Turbo (turbo-rails gem) (this will change of course once the official version is released)

# Gemfile

gem "turbo-rails", "~> 2.0.0-beta.2"

Then run

bundle install

Don’t forget to update the node version of Turbo as well

yarn add @hotwired/[email protected]

Create the blog

Scaffold a Post resource. We’ll make it reference a User model that I'll create next.

rails g scaffold Post title content:text user:references

Scaffold a User

rails g scaffold User name email

Note: For the guide I’ll forgo worrying about authentication so we’re keeping this stupid simple for now.

Create a User on the rails command line rails console.

User.create(name: "Andy", email: "[email protected]")

  TRANSACTION (0.0ms)  begin transaction
  User Create (0.5ms)  INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id"  [["name", "Andy"], ["email", "[email protected]"], ["created_at", "2023-12-21 20:31:06.848693"], ["updated_at", "2023-12-21 20:31:06.848693"]]
  TRANSACTION (0.4ms)  commit transaction
=>
#<User:0x00000001090775b0
 id: 1,
 name: "Andy",
 email: "[email protected]",
 created_at: Thu, 21 Dec 2023 20:31:06.848693000 UTC +00:00,
 updated_at: Thu, 21 Dec 2023 20:31:06.848693000 UTC +00:00>
irb(main):002>

Update the associations

After generating the resources we can define the associations in the models.

# app/models/user.rb

class User < ApplicationRecord
  has_many :posts
end

And then in the Post model:

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
end

Standard Rails stuff!

Update routing

Inside config/routes.rb we can uncomment the line beginning with root

Rails.application.routes.draw do
  resources :users
  resources :posts
  # 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 "posts#index"
end

Stub out the controller

Normally in the controller, you might define a current user based on some authentication strategy. To make this demo “just work” I’ll stub it out with some crappy code assigning the first User directly :)

class PostsController < ApplicationController
   def create
    # assign the User record we just made for demo purposes
    @post = Post.new(post_params.merge(user: User.first))

    respond_to do |format|
      if @post.save
        format.html { redirect_to post_url(@post), notice: "Post was successfully created." }
        format.json { render :show, status: :created, location: @post }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end
end

Remove user references from the Post form

Because we are doing this in our controller we don’t need the user_id field. Let's remove it so we are left with the following:

<%= form_with(model: 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 :title %>
    <%= form.text_field :title, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
  </div>

  <div class="my-5">
    <%= form.label :content %>
    <%= form.text_area :content, rows: 4, 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 %>

Move the Post form to the index

Let’s make our form super simple and display it above all the posts on the index. Maybe think of it like a makeshift “X” (formerly Twitter) app.

<div class="w-full max-w-3xl 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 %>

  <h1 class="font-bold text-4xl">Posts</h1>

  <%= render "posts/form", post: Post.new %>

  <div id="posts" class="min-w-full mt-10">
    <%= render @posts %>
  </div>
</div>

From here go ahead and create a post or two. You can use the console or go through the UI but we just need a post or two as dummy data to get started.

Add new morph meta tag

In your application.html.erb layout file add the following within the head ta

<%= turbo_refreshes_with method: :morph, scroll: :preserve  %>
<head>
  <title>Morphblog</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <%= csrf_meta_tags %>
  <%= csp_meta_tag %>
  <%= turbo_refreshes_with method: :morph, scroll: :preserve  %>

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

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

This instructs the app to "morph" the page while preserving the scroll position. Previously, Turbo would "replace" the entire page and "reset" the scroll position.

Update Post model

Next, we need to broadcast the updates. With the new beta version of Turbo, we can add the following to our Posts model.

# app/models/post.rb

class Post < ApplicationRecord
  belongs_to :user
  broadcasts_refreshes # add this line
end

Then in the posts#show view, we can add the turbo_stream_from tag you might already be familiar with.

<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 %>

    <%= turbo_stream_from @post %>

    <%= render @post %>

    <%= link_to "Edit this post", edit_post_path(@post), class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
    <div class="inline-block ml-2">
      <%= button_to "Destroy this post", post_path(@post), method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
    </div>
    <%= link_to "Back to posts", posts_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
  </div>
</div>

Watch a quick example of the “streamed” updates in action. I opened two windows, one being incognito. It kinda “just works” which is way cooler than tons of turbo frame tags and whatnot.

Updating collections

This is one area I’m not 100% sold on. It still works but there’s a big assumption with a collection you’ll have an association tied to it. That’s probably true most of the time but it feels a bit rigid. I might be overthinking it of course.

Using the new morph features to make something like the posts index stream updates we need an association tied to it. So instead of just rendering all the posts, we’ll render the user’s posts and update our models.

Here’s the Post model

# app/models/post.rb
class Post < ApplicationRecord
  # updates the timestamp when a post-commit (create, update, destroy) occurs
  belongs_to :user, touch: true
  broadcasts_refreshes
end

The touch: true addition instructs the timestamp of the associated user when it's manipulated in some way.

On the User model we need to add the new method

# app/models/user.rb
class User < ApplicationRecord
  has_many :posts
  broadcasts_refreshes
end

Then in the index view, we’ll stub out what might normally be a current_user with the associated user we defined for demonstration purposes.

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :set_post, only: %i[ show edit update destroy ]

  # GET /posts or /posts.json
  def index
    @user = User.first
    @posts = @user.posts # update to user posts
  end

  # more stuff below
end

Then we need to stream updates from that User specifically.

<div class="w-full max-w-3xl 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 %>

  <h1 class="font-bold text-4xl">Posts</h1>

  <%= render "posts/form", post: Post.new %>

  <%= turbo_stream_from @user %>
  <div id="posts" class="min-w-full mt-10">
    <%= render @posts %>
  </div>
</div>

Here it is in action. Pretty darn slick!

Wrapping up

Having dabbled with the beta I’m super excited about this update!

It makes reaching for turbo frame tags more intentional versus necessary. There will no doubt be times when you need more sophistication but I think this is a huge improvement and one I’m excited to transition into existing apps leveraging a ton of turbo frames. The fact that I need to add about 3 lines of code is probably my favorite part. Cheers to the future of Rails!

Tags: morph rails ruby
Link this article
Est. reading time: 9 minutes
Stats: 4,277 views

Categories

Collection

Part of the Hotwire and Rails collection

Products and courses