This article was originally published on Rails Designer
A few weeks ago I helped someone start their first SaaS. They had found a really cool, small problem for a specific niche that made for a great small business. They already are serving the first handful of customers. But I digress… The main view of the app was a list of records. And as the app would be perfect to have in your "pinned tabs", a counter with new records was a good feature to add.
This article goes over how easily this can be done with a (custom) Turbo Stream in Rails. I have written about custom turbo streams before, but did not touch upon how to cleanly write them yourself.
It will look something like this:
Notice how the the title updates with the message count?
As always the code can be found on GitHub.
Creating a custom turbo stream action
To demonstrate the title counter, I created a simple message system in the repo. This is just scaffolding to show the counter in action, so I will not go over it.
The goal now is to update the page title with a counter whenever messages are created or destroyed. Rails ships with several built-in Turbo Stream actions like append, prepend, replace and remove. But updating the page title requires a custom action.
Starting from the outside, the index view sets the initial title and displays the message count:
-<% content_for :title, "Messages" %>
+<% content_for :title, @count.positive? ? "#{@count} • Messages" : "Messages" %>
The controller provides the count:
def index
@messages = Message.all.reverse
+ @count = @messages.count
end
In the Turbo Stream responses, the custom action is called alongside the standard ones:
<%# app/views/messages/create.turbo_stream.erb %>
<%= turbo_stream.prepend "messages", partial: @message %>
<%= turbo_stream.set_title_counter @count %>
<%# app/views/messages/destroy.turbo_stream.erb %>
<%= turbo_stream.remove @message %>
<%= turbo_stream.set_title_counter @count %>
See how set_title_counter is called just like any built-in Turbo Stream action. This is the clean API I am looking for.
Now create the helper method for the custom Turbo Stream tag:
# app/helpers/turbo_stream_actions_helper.rb
module TurboStreamActionsHelper
def set_title_counter(count, divider: nil)
turbo_stream_action_tag :set_title_counter, count: count, divider: divider
end
end
Turbo::Streams::TagBuilder.prepend(TurboStreamActionsHelper)
The turbo_stream_action_tag method is Rails' way of creating custom Turbo Stream actions. It generates an HTML tag with the action name and any attributes you pass. The prepend line makes this helper available on the turbo_stream object in views.
The turbo stream tag
Before diving into the JavaScript part of the custom Turbo Stream action, it helps to understand what HTML this helper generates. When you call turbo_stream.set_title_counter @count, it produces a tag like this:
<turbo-stream action="set_title_counter" count="5">
<template></template>
</turbo-stream>
This is the same structure as built-in Turbo Stream actions. The action attribute tells Turbo which JavaScript function to call (created next). The count attribute is custom data that the JavaScript action reads. The empty <template> tag is required by the Turbo Stream format, even though this action does not insert any HTML into the page.
When Turbo encounters this tag, it looks up the set_title_counter function in Turbo.StreamActions and executes it. It looks like this:
// app/javascript/turbo_stream_actions/set_title_counter.js
export default function() {
const count = this.getAttribute("count") || 0
const divider = this.getAttribute("divider") || "•"
const title = document.title
const baseTitle = title.includes(divider) ? title.split(divider).pop().trim() : title
document.title = count > 0 ? `${count} ${divider} ${baseTitle}` : baseTitle
}
This function reads the count and divider attributes from the Turbo Stream tag. It then extracts the base title by removing any existing counter. If the count is greater than zero, it prepends the count with the divider. Otherwise just the base title.
The logic for extracting the base title is important. It checks if the current title already contains a counter by looking for the divider. If found, it splits on the divider and takes the last part. This ensures the counter does not stack up with repeated updates.
Now to make it work, lets register it with Turbo:
// app/javascript/turbo_stream_actions/index.js
import { Turbo } from "@hotwired/turbo-rails"
import set_title_counter from "turbo_stream_actions/set_title_counter"
Turbo.StreamActions.set_title_counter = set_title_counter
Import in the main application file:
import "@hotwired/turbo-rails"
import "controllers"
+import "turbo_stream_actions"
And let importmap know about the new directory:
pin_all_from "app/javascript/controllers", under: "controllers"
+
+pin_all_from "app/javascript/turbo_stream_actions", under: "turbo_stream_actions"
(this is importmap only and not something needed if you use NPM)
Then finally make sure the controller actions to provide count (@count = Message.all.count). Now the new custom action is all wired up and working as seen in the GIF above. Yay!
The new custom Turbo Stream action works great for direct user interactions. But what about updates from other users or background jobs? Turbo Stream broadcasts handle this just as well. The Message model broadcasts changes and triggers the title counter update:
class Message < ApplicationRecord
+ broadcasts :messages, inserts_by: :prepend
+
+ after_commit :set_title_counter
+
def title = "Message #{id}"
+
+ private
+
+ def set_title_counter
+ broadcast_action_to :messages, action: :set_title_counter, attributes: { count: Message.count }
+ end
end
The broadcasts line handles the standard create, update (not shown in the repo, but could be used to mark a message as "read") and destroy broadcasts. The after_commit callback sends the custom title counter update after any database change.
The broadcast_action_to method is similar to the helper created earlier. It sends a Turbo Stream action over the broadcast channel. The attributes hash becomes the HTML attributes on the tag, which the JavaScript action reads.
And lastly subscribe to the broadcast in the view:
<% content_for :title, @count.positive? ? "#{@count} • Messages" : "Messages" %>
+<%= turbo_stream_from :messages %>
Now when any user creates or deletes a message, all connected clients see their title counter update in real-time.
This new custom action integrates nicely with Rails' Turbo Streams. It works in direct responses and also broadcasts. And because it is just JavaScript, you can extend it with animations, notifications or any other browser API you need. Cool, right?

Top comments (0)