Live, asynchronous updates to RoR applications views from the backend
Krzysztof Kamil Piotrowski
2N IT
kk.pio@protonmail.com
# Agenda
# Basics
<turbo-stream action="update" target="id-of-element-to-update">
<section>
<div id="id-of-element-to-update">
<!-- Something -->
</div>
</section>
</turbo-stream>
# Basics
This is how it looks in pure HTML (ofc. rails gives helpers to generate that, coming up in the next slide)
# Controller
def create
@post = Post.new(post)
respond_to do |format|
if @post.save
format.html { redirect_to @post, notice: "Post was successfully created." }
format.turbo_stream
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
# app/views/posts/create.turbo_stream.erb
<%= turbo_stream.replace "posts_form" do %>
<%= render partial: "new_post" %>
<% end %># Basics
<turbo-stream action="replace" target="posts_form">
<section>
<!-- Content of response -->
<turbo-frame id="posts_form">
<a href="/posts/new">New Post</a>
</turbo-frame>
</section>
</turbo-stream>
When broadcast is sent, something like this appear in the DOM for a brief moment
Turbo internal JS kicks in, changes the HTML. You do not have to do anything else on the FE
# Basics
This makes for some pretty impressive stuff
# Basics
But all of this you can learn from the documentation. https://turbo.hotwired.dev/handbook/streams
For now the documentation is sparse and covers just the basics. If you want a more advanced turbo usage, you are left with just the source code and turbo-rails gem for help
The gem for turbo on rails is good (IMHO), and the source code good is clean and has a lot of comments that are helpful for us to use TurboStream in a more interesting way
There is still little resources so far around Turbo, and most of them are repetitive, show you the same thing that the documentation does
We do not want to always have to deal with partials and put everything in the controller or add even more stuff to already big rails models
There are multiple operations in most systems, that affect the data of the application, that in turn affects the view. Why not simplify and speed up the process of clicking through and refreshing pages? Basically lets introduce side effects of backend actions to the view more directly, without coupling
# Our app and stack
Gems and tech stack:
Application
Our demo case:
# The end result
Videos presentation link
What this showcases is:
What we need to achieve with the implementation
# The code
# 1 HTML
<%= cell(::Posts::Cell::Index, Post.all, layout: TurboPrez::Cell::DefaultLayout).(:turbo) %>
<%= cell(::Posts::Cell::Index, Post.all, layout: TurboPrez::Cell::DefaultLayout).(:show) %>
Default turbo approach involves adding html (or rails helpers) to a lot of partials if you use turbo the default way. This often also leads to more code in controllers (some parts of turbo require special format handling). If you add turbo to an existing rails app, you will end up adding quite a lot of HTML and controller code (still better than a lot of JS). Using view components like cells help minimazie that, this is the whole change in the view invocation
Minimize changes in html
# 1 HTML
module Cell
module TurboStream
DEFAULT_CHANNEL = 'ApplicationCable::Channel'.freeze
include ::Turbo::IncludesHelper
include ::Turbo::StreamsHelper
def turbo
turbo_show = content_tag(:div, id: target_id) do
show
end
[turbo_stream_tag, turbo_show].join.html_safe
end
def turbo_stream_tag
turbo_stream_from(
{ stream_id: self.class.base_stream_id }, channel: self.class.channel_name
)
end
def target_id
self.class.name.to_s.underscore.gsub(%r{/|_}, '-')
# You can mix in user id into this to make streams per user, i.e.
# id = [self.class.name.to_s.underscore.gsub(%r{/|_}, "-")]
# id << model.id if model.respond_to?(:id)
#
# id.join('-')
end
end
endMinimize changes in html continued
# 2 Cell opt-in
module Posts
module Cell
class Index < ::Trailblazer::Cell
alias posts model
include ::Cell::TurboStream
register_turbo_rendering(Post) do |params|
instance = new(params.model, context: params.context)
html = -> { instance.(:turbo) }
TurboStream::Actions::Update.new(html, target: instance.target_id)
end
end
end
end
It is also nice to have a ruby object that determines what to do and when, with the broadcast signal received
Opt-in through cell
I wanted to use cells/githubs view components cause they are mose reusable and make the htmls smaller. Also it is hard to render a partial from for example a sidekiq worker. Also it makes it easy to search for all components that use turbo
# 2 Cell opt-in
module Cell
module TurboStream
def self.included(base)
base.send :extend, ClassMethods
end
module ClassMethods
@channel = nil
def register_turbo_rendering(record, &block)
::TurboStream::Cells.register(self, record, base_stream_id, channel_name, block)
end
def channel(name)
@channel = name
end
def channel_name
@channel || DEFAULT_CHANNEL
end
def base_stream_id
@existing_turbo_stream || name.to_s.underscore.gsub(%r{/|_}, '-')
end
end
end
end
Opt-in through cell continued
# 3 Agnostic BE
class IndexPostsJob
include Sidekiq::Job
def perform
posts = Post.all.sample(7).shuffle
::TurboStream::Cells.render_for(posts, updates: { posts: posts })
end
end
I dont want the BE to know what cell its rendering since that would couple it to the view
Decide how to render in cell
From external places that can affect the view it would be nice to simply call always the same object with as simple parameters as possible and be done with it. Do not couple the view with the backend.
# 3 Agnostic BE
module TurboStream
module Cells
ActionsParams = Struct.new(:context, :model, :updates, keyword_init: true)
class << self
include ::Turbo::StreamsHelper
REGISTRY = ::TurboStream::SubscriptionRegistry.instance
def register(registered_by, record, stream_id, channel, block)
REGISTRY.register_new_stream(registered_by.to_s, record.to_s, stream_id, channel, block)
end
def render_for(record, updates: {})
registry_entry_id = record.is_a?(Array) ? record.first : record
registrations[registry_entry_id.class.to_s].each do |render_hook|
next unless stream_active?(render_hook.stream_id)
channel = render_hook.channel.constantize
streamables = stream_handler.stream_name(stream_id: render_hook.stream_id)
actions_params = ActionsParams.new(
context: { controller: controller },
model: record,
updates: updates
)
actions = render_hook.resolve_actions(actions_params)
Array(actions).each do |action|
broadcast_action(streamables, channel: channel, action: action)
end
end
end
private
def stream_handler
@stream_handler ||= TurboStream::StreamsHandler
end
def stream_active?(stream_id)
stream_handler.live_stream_is_present?(stream_id: stream_id)
end
# Some nasty stuff connected to rails needing to have controller context to generate path
def controller
url_settings = {:host=>"localhost", :port=>3000}
request = ActionDispatch::Request.new(url_settings)
instance = ApplicationController.new
instance.set_request! request
end
def broadcast_action(streamables, channel:, action:)
case action
when TurboStream::Actions::Update
channel.broadcast_update_to(streamables, target: action.target, html: action.html)
when TurboStream::Actions::Append
channel.broadcast_append_to(streamables, target: action.target, html: action.html)
when TurboStream::Actions::Remove
channel.broadcast_remove_to(streamables, target: action.target)
end
end
end
end
end
Decide how to render in cell continued
We want to use a general object to avoid coupling stuff like workers to cells
# 4 No useless broadcasts
I need BE to know that someone will receive a broadcast, to not render html without a need
Since backend is decoupled from the user sessions and you can't access ActionCable easily from anywhere I wanted to avoid simply always broadcasting in hopes someone is listening. Do not waste resources, specially given ActionCable problems with being up to date
Only render if stream active
# 4 No useless broadcasts
module ApplicationCable
class Channel < ActionCable::Channel::Base
extend Turbo::Streams::StreamName
extend Turbo::Streams::Broadcasts
include Turbo::Streams::StreamName::ClassMethods
periodically :check_stream_activity, every: 15.seconds
def subscribed
TurboStream::StreamsHandler.add_stream(signed_stream_name: params["signed_stream_name"])
stream_from params["signed_stream_name"]
end
private
def check_stream_activity
actual_streams = ActionCable.server.pubsub.send(:redis_connection).pubsub('channels')
our_streams = TurboStream::StreamsHandler.active_streams
inactive_streams = our_streams.reject do |stream|
actual_streams.include?(stream.signed_stream_name)
end
inactive_streams.each do |inactive_stream|
stop_stream_from inactive_stream.signed_stream_name
TurboStream::StreamsHandler.remove_stream(
signed_stream_name: inactive_stream.signed_stream_name
)
end
end
end
end
Only render if stream active continued
Some other usages this could give us:
# Summary
Turbo Stream
Can be used to make BE business logic affect FE for live users immedietly, without users reloading the page. This can be done from any place in the code basically, not just the controller-model that are tied to specific view partial as the default Turbo might suggest
Uses Action Cable but is missing some tools that would make it much easier to use, for example there is no easy way to detect current live session/clients. You need to dig into redis if you configure Action Cable to use it, you check that.
Its a tool with great potential, its fast and the source code of the ruby gem, that serves as a bridge to the actual Turbo written in Typescript, is written well
Its a young tool with most of the materials on the web so far only repeating the documentation Basecamp provided, not being very innovative
So far the best, most comprehensive Hotwire (mostly focused on Turbo) tutorial I saw was this: https://www.hotrails.dev
This presentation used a sample app, which code is public on: https://github.com/krzykamil/turbo_prez
# Resources