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!
Categories
Collection
Part of the Hotwire and Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.