A Simpler Build with Broadcasts Refreshes Discussion
My general rule of thumb is I'll use redirecting back/morphing for very simple or low-frequency pages such as admin pages where there's not a lot of user traffic, but will reach for turbo streams when the page has multiple/expensive DB queries. The thing I can't really get over is re-executing all the DB queries, processing the full page, and then DOM morphing out the pieces to change. That's a lot of overhead when all I usually need to do is update a few elements on the page or add a new one. It is convenient for simple pages, and does reduce developer complexity, but I'd only use them sparingly on complex pages.
This is my #1 issue with hotwire: there isn't a cleaner "built-in" way to easily update attributes or data on the page without a decent amount of manually wrapping and updating. I spent the last week building out a working version where I can list attributes for a record and turbo stream can update them easily via:
# app/views/users/update.turbo_stream.erb
<%= turbo_stream.replace_wrapped_attributes(@user, :name, :email, :phone_number) %>
It uses a custom turbo action and a #wrap
method in the model that outputs the attributes with a span dom_id wrapper:
# app/models/application_record.rb
# Wrap attributes so they can easily be updated via TurboStreams.
# Requires the model to define #attributes_to_wrap.
def wrap(attribute)
entry = attributes_to_wrap[attribute].presence || {}
formatter = entry.fetch(:formatter, nil)
default = entry.fetch(:default, nil)
component = AttributeComponent.new(self, attribute, formatter: formatter, default: default)
ApplicationController.new.view_context.render(component)
end
# a view file
<%= @user.wrap(:name) %>
# => <span class="name_user_1">John Smith</span>
Since I'm using Github ViewComponents in my app, I even allowed formatting of data such as phone numbers in the model:
# app/models/user.rb
def attributes_to_wrap
{
name: { formatter: nil, default: "No name" },
phone_number: { formatter: :phone, default: "No phone number" }
}
end
Then I have a generic AttributeComponent
that handles formatting based on what it reads from the model. This certainly violates some concepts of MVC, but when updating an attribute dynamically on the page from the back-end, I need to know how to format the data, and the only way I could cleanly get that was adding some "wrapping" content to the model and returning a ViewComponent object with formatting built into it.
class AttributeComponent < ApplicationComponent
attr_reader :model, :attribute, :formatter, :default
def initialize(model, attribute, formatter: nil, default: nil)
@model = model
@attribute = attribute
@formatter = formatter
@default = default.presence || "No #{@attribute.to_s.downcase.humanize}"
end
def formatter?
@formatter.present?
end
def raw_attribute
@raw_attribute ||= model.public_send(attribute)
end
# Format the value if an optional formatter is provided
def formatted_value
case formatter
when :phone
helpers.number_to_phone(raw_attribute.to_s)
else
raw_attribute # Unknown formatter
end
end
def attribute_identifier
model.dom_id(attribute)
end
def value
if raw_attribute.present?
formatter? ? formatted_value : raw_attribute
else
default
end
end
def call
content_tag(:span, value, class: attribute_identifier, data: { view_component: true, wrapped_attribute: true })
end
end
Similar to the argument for a global Current
object in Rails, I violate the principle when it makes sense and saves me a ton of time, and the above code saves me dozens of lines of code in various turbo_stream.erb files.
Hope others may find it useful or recommend ways to make it better!
Really enjoying the hotwire episodes Collin, keep them coming!