Turbo Stream

Live, asynchronous updates to RoR applications views from the backend

Krzysztof Kamil Piotrowski

2N IT

kk.pio@protonmail.com

# Agenda 
  1. Turbo!!
    • Turbo Stream basics
  2. Demo app
    • Step by step analysis
  3. Summary
  4. Resources - QA

TLDR; Turbo, Hotwire:

  • Turbo is a part of Hotwire (its heart along with Stimulus)
  • Hotwire minimizes the need for Javascript (huray!), by delivering HTML "over the wire"
  • Hotwire is used more and more in RoR environment
  • Focus with TurboStream and Hotwire overall is to render templates on the server side, deliver them to the client without rendering the whole page, but only the needed part of it
  • This takes out the need to refresh the whole page to see the changes resulting from client actions on the page, this applies to everything from form submissions to path changes (SPA-like)
  • TurboStream "delivers page changes over WebSocket, Server-Sent Events or in response to form submissions"
# Basics

Basic stream in html template

<turbo-stream action="update" target="id-of-element-to-update">
  <section>
    <div id="id-of-element-to-update">
      <!-- Something -->
    </div>
  </section>
</turbo-stream>
# Basics

Basic stream in html template

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

Regular, basic rails usage

# 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

  • Real time updates to pages - no reloads
  • Faster development of common and uncommon FE tasks and problems
  • No Javascript
  • Reactive elements with no javascript
  • Server-side handling of frontend problems
  • No JS
  • Can be used outside Rails (although, in this case has to be used through DSL in Typescript)
  • Other than that, using Turbo minimizes or obliterates your need for Javascript
# 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
  • Sidekiq
  • Trailblazer Cells (you could replace that with githubs ViewComponents)
  • turbo-trails gem (just for the streams)
  • redis (used by ActionCable that in turn is used by TurboStream)

Gems and tech stack:

Application

  • Blog (original I know)
  • Rails 6.0.2
  • Ruby 3.0.2

Our demo case:

  • User is displaying a index page of a blog
  • Author of the blog has created a structure of the page that has 1 post exposed, then two in a next line, and then 3 in the next line
  • Author of the blog wants the entire structure of what post is exposed or in what line to change from time to time, without the users reloading to see the change
# The end result

Videos presentation link

What this showcases is:

  • A big part of the page can be updated - the cell is a part of the entire view, it is not the entire view
  • The layout was not rendered again, just the cell html was redone
  • rake task that fires sidekiq and sidekiq itself had no knowledge of client session, yet the client view was altered
  • Pretty much everything happens on server side, no JS was written here by me

What we need to achieve with the implementation

# The code
  1. Minimize changes in html
  2. Opt-in through cell
  3. Decide how to render in cell
  4. Only render if stream active
# 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
end

Minimize 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:

  • Could be integrated with event sourcing (the original usage I worked with)
  • Could work across microservices
  • As well as gems (this implementation itself could be a gem)
  • As well as rails engines
  • In short - as long as you can access the redis instance and registry of cells using streams you can make it work
# 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
Made with Slides.com