Skip to content

Useful Features to implement with Turbo 8 in Rails 8

This article will explore Turbo 8’s powerful features and a little bit of code. I swear, you will be surprised by how little extra code is needed to implement all of this!

We will be starting over but using many of the same concepts from the previous article. Let’s get into it!

Setup

First, we can create a new Rails app from the main branch to get all of the Rails 8 goodies (though, soon, this will be available through alpha/beta gem releases).

Terminal window
rails new --main blabber

Now, as we will be adding a few features related to user interactions, we will need to create users and a way to log them in. Short of using the new Rails 8 authentication generators, we can add two quick models, a small number of controller methods, and a few view templates.

First, the models via Rails generators:

Terminal window
rails g model user email:string password_digest:string
rails g model post user:references message:text

These generators will create two models and migrations for the Users and Posts. Let’s add a few lines to the User model for validation and authentication:

class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
has_secure_password
end

The significant addition here is the has_secure_password line which includes code to set and authenticate passwords via bcrypt. Now, on the Post model, the user reference should exist, and we can add a quick validation:

class Post < ApplicationRecord
belongs_to :user
validates :message, length: { minimum: 1, maximum: 280 }
end

Finally, here is a quick bin/rails db:migrate to get those tables created.

Now, we can move on to the controller and views for logging in. We want to make this simple, just for this tutorial, so we’ll handle logging in and logging out, their responsive routes, and some basic views.

First, the routes:

Rails.application.routes.draw do
get "/login", to: "sessions#new", as: "login"
post "/sessions", to: "sessions#create"
get "/logout", to: "sessions#destroy", as: "logout"
resources :posts, only: %i[index create]
root "posts#index"
end

These will be all the routes we need for the whole app: the first three for sessions, a resource helper for posts, and setting the root route.

Now we can add the SessionsController at app/controllers/sessions_controller.rb:

class SessionsController < ApplicationController
def create
@user = User.find_by(email: params.dig(:user, :email))
if @user && @user.authenticate(params.dig(:user, :password))
sign_in(@user)
redirect_to root_path, notice: "You have successfully logged in!"
else
flash.now[:alert] = "There was a problem logging in."
render :new, status: 422
end
end
def destroy
sign_out
redirect_to login_path, notice: "You have successfully logged out!"
end
private
def sign_in(user)
session[:user_id] = user.id
end
def sign_out
session.delete(:user_id)
end
end

Pretty simple here. If we find a user and that user authenticates with the included authenticate method from the User model, we will call sign_in, which sets a session for the user. Real quick, we can add a helper method in ApplicationController to use current_user throughout controllers and views through the app:

def current_user
@current_user = User.find_by(id: session[:user_id]) if session[:user_id]
end
helper_method :current_user

Next, we can quickly add a PostsController that resembles much of what we used in the last tutorial, aside from now having a user_id:

class PostsController < ApplicationController
before_action :require_login
def index
@posts = Post.all.order(created_at: :desc)
@post = Post.new
end
def create
@post = Post.new(post_params.merge(user_id: current_user.id))
respond_to do |format|
if @post.save
redirect_to posts_path
else
render :index
end
end
end
private
def post_params
params.require(:post).permit(:message)
end
end

Both the index and create actions are reasonably straightforward Rails-like methods for listing and creating Posts. The last part of the authentication puzzle here would be to add the before_action:require_login method to the ApplicationController and use the Rails Console to create two users (thus, we skipped building a signup interface).

app/controllers/application_controller.rb
## redirect to login, if there is no current_user, meaning there is no authenticated user session
def require_login
redirect_to login_path, alert: 'You must be logged in to access this page.' if current_user.nil?
end

Lastly, we can go ahead and knock out a few views to get all of the authentication, Bootstrap, and basic Post markup out of the way

app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "Blabber" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<% if current_user %>
<meta name="current-user-id" content="<%= current_user.id %>">
<% end %>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%= yield :head %>
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<%# Includes all stylesheet files in app/views/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body class="">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="/">Blabber</a>
<span class="navbar-text">
<% if current_user %>
<%= current_user.email %>
<%= link_to "Logout", logout_path, method: :delete, class: "btn btn-outline-danger" %>
<% else %>
<%= link_to "Login", login_path, class: "btn btn-outline-primary" %>
<% end %>
</span>
</div>
</nav>
<% if flash[:notice] %>
<div class="alert alert-success" role="alert">
<%= flash[:notice] %>
</div>
<% end %>
<% if flash[:alert] %>
<div class="alert alert-danger" role="alert">
<%= flash[:alert] %>
</div>
<% end %>
<div class="container mt-5">
<%= yield %>
</div>
</body>
</html>

A few code blocks to note:

<% if current_user %>
<meta name="current-user-id" content="<%= current_user.id %>">
<% end %>

…will add the user ID to the front end for use in StimulusJS later.

<%= turbo_refreshes_with method: :morph, scroll: :preserve %>

…will allow Turbo 8 to morph the incoming DOM changes sent by Turbo.

Finally, Bootstrap is added over CDN for the simplicity of this tutorial, as well as a simple navbar and alerts.

Ok, three Post templates, and we’re done with setup.

To display the list of posts, you can create an index.html.erb file in the app/views/posts directory. Here’s an example of how the index view could look like:

<div class="container">
<h1>Blabber</h1>
<h4>A Rails, Hotwire demo</h4>
<%= render partial: 'form' %>
<%= render @posts %>
</div>

This code uses the _post.html.erb partial to render each post in the @posts collection.

<div class="card mb-2" id="<%= dom_id(post) %>">
<div class="card-body">
<h5 class="card-title text-muted">
<small class="float-right">
Posted <%= time_ago_in_words(post.created_at) %> ago
</small>
<%= post.user.email %>
</h5>
<div class="card-text lead mb-2"><%= post.message %></div>
</div>
</div>

…and then the _form.html.erb

<%= form_with model: @post, id: dom_id(@post), html: {class: 'my-4' } do |f| %>
<% if @post.errors.any? %>
<div id="error_explanation">
<h2>
<%= pluralize(@post.errors.count, "error") %> prohibited this post from
being saved:
</h2>
<ul>
<% @post.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group" data-controller="typing">
<%= f.text_area :message, placeholder: 'Enter your blab', class: 'form-control',rows: 3 %>
</div>
<div class="actions my-2">
<%= f.submit class: "btn btn-primary" %>
</div>
<% end %>

This code uses the form_with helper to create a form for the @post object. The text_area helper creates a textarea input for the post’s message attribute.

Ok! That’s enough setup code!

Turbo Morph

If you followed along with the previous tutorial, there are only a couple of lines to add to get morphed updates out of the box! Earlier, we pre-empted the code to add with the line to the partial. To finish this off, we will add a line to the Post model and two through the Post views.

First, the line to the model:

class Post < ApplicationRecord
belongs_to :user
broadcasts_refreshes ## New line
validates :message, length: { minimum: 1, maximum: 280 }
end
app/views/posts/index.html.erb
<div class="container">
<h1>Blabber</h1>
<h4>A Rails, Hotwire demo</h4>
<%= render partial: 'form' %>
<%= turbo_stream_from 'posts' %> <!-- This is that streams from the post collection -->
<%= render @posts %>
</div>
app/views/posts/_post.html.erb
<%= turbo_stream_from post %> <!-- This is that streams from each post -->
<div class="card mb-2" id="<%= dom_id(post) %>">
<div class="card-body">
<h5 class="card-title text-muted">
<small class="float-right">
Posted <%= time_ago_in_words(post.created_at) %> ago
</small>
<%= post.user.email %>
</h5>
<div class="card-text lead mb-2"><%= post.message %></div>
</div>
</div>

That’s it! If you create two users in the console, log on in, and then log another in on an incognito tab, you will see each page get updated with new posts from each user!

Presence Channel

Presence channels are a great way to show who is online in your application.

In our case, we will use an indicator next to the users’ email addresses in the Post card. This indicator will be a simple green dot if the user is online and a red dot if the user is offline.

The first step here will be to include icons to use a circle and then color it based on the user’s presence. We can easily include Bootstrap icons by adding the following line to the application.css file:

@import url("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css");

Now, we can generate a quick migration to add an online boolean to the User model:

Terminal window
rails g migration add_online_to_users online:boolean
rails db:migrate

Next, we can add two simple methods to the User to adjust a user’s presence status using the online boolean. We will also add a method to broadcast changes to the presence channel after a user’s status changes and check for the online_previously_changed? (a built-in ActiveRecord field status method) in that method used by the callback:

app/models/user.rb
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
has_secure_password
after_commit :broadcast_changes
def broadcast_changes
if online_previously_changed?
Turbo::StreamsChannel.broadcast_refresh_to(:presence)
end
end
def came_online
update!(online: true) unless online?
end
def went_offline
update!(online: false) if online?
end
end

Finally, we can add a line to the application.html.erb to listen for changes to the presence channel and then update the _post partial to include the presence indicator:

app/views/layouts/application.html.erb
...
<%= turbo_stream_from :presence, channel: PresenceChannel if current_user %>
...

The turbo_stream_from:presence will use the turbo_stream helpers from the turbo-rails gem to handle the automatic subscription to the presence channel. The PresenceChannel argument in this method sets which Channel will be specifically used in the streaming process.

app/views/posts/_post.html.erb
<%= turbo_stream_from post %>
<div class="card mb-2" id="<%= dom_id(post) %>">
<div class="card-body">
<h5 class="card-title text-muted">
<small class="float-right">
Posted <%= time_ago_in_words(post.created_at) %> ago
</small>
<i class="bi bi-circle-fill align-middle text <%= post.user.online? ? 'text-success' : 'text-danger'%>" style="font-size: .5rem"></i>
<%= post.user.email %>
</h5>
<div class="card-text lead mb-2"><%= post.message %></div>
</div>
</div>

This uses the bi-circle-fill icon from Bootstrap and changes its color based on the user’s presence status.

Lastly, we can create the PresenceChannel in the app/channels directory:

app/channels/presence_channel.rb
class PresenceChannel < ActionCable::Channel::Base
extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
include Turbo::Streams::StreamName::ClassMethods
def subscribed
stream_from "presence"
current_user.came_online
end
def unsubscribed
current_user.went_offline
end
end

This channel extends and includes some Turbo::Streams modules, so that it can broadcast to the presence channel and handle the stream name. The subscribed method will stream from the presence channel and then call the came_online method on the current user. The unsubscribed method will call the went_offline method on the current user. unsubscribe is a method that is called when the client disconnects from the channel by closing the window or logging out.

Now, if you tried to use the browser, you would see errors from your channel in the logs, as we have to set up one last piece, the current_user in the Connection class. This class is much like an ApplicationController but for the ActionCable connections. We can add a method to the Connection class to set the current_user:

app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
if current_user = User.find_by(id: @request.session[:user_id])
current_user
else
reject_unauthorized_connection
end
end
end
end

This module will set the current_user to the user found by the session[:user_id], which was set during the login, or reject the connection if the user is not found.

If you were to open two browsers (your current browser window and an incognito would work!) now and log into each user, you would see the presence indicator change in real time!

Typing Indicators

The last feature we’ll build here is a typing indicator, which will show when a user is typing a message, much like you see in Slack. Of course, as this is just a simple app to demonstrate the capability, the typing indicator will be shown to all users but not the user typing.

First, we can generate a channel to handle the typing events and pin a debounce library to handle the typing events in a javascript controller a few files later:

Terminal window
bin/rails generate channel typing
bin/importmap pin lodash.debounce

Inside the TypingChannel, we can add a few methods to handle the typing and typing stopped events from the “Cable” side:

app/channels/typing_channel.rb
class TypingChannel < ApplicationCable::Channel
def subscribed
stream_from "typing"
end
def typing
ActionCable.server.broadcast("typing", { action: 'typing', uid: current_user.id.to_s, user_email: current_user.email } )
end
def typing_stopped
ActionCable.server.broadcast("typing", { action: 'typing_stopped', uid: current_user.id.to_s, user_email: current_user.email } )
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end

This channel will stream from the typing channel and then broadcast the typing and typing_stopped events to the channel. The typing event will include the current user’s uid and user_email. This information is needed to do two things. One, the user_id will be used to filter out the currently typing user from seeing the typing indicator. Second, the email address is used to display the message so as not to send the data to the front end differently. The unsubscribed method was added to the channel by default during the generation of the channel but can be removed as it is not needed.

With that out of the way, the next place to add some code is the markup by adjusting and adding some StimulusJS to the Posts form:

app/views/posts/_form.html.erb
<%= form_with model: @post, id: dom_id(@post), html: {class: 'my-4' } do |f| %>
<% if @post.errors.any? %>
<div id="error_explanation">
<h2>
<%= pluralize(@post.errors.count, "error") %> prohibited this post from
being saved:
</h2>
<ul>
<% @post.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group">
<div class="form-group" data-controller="typing">
<%= f.text_area :message, placeholder: 'Enter your blab', class: 'form-control', rows: 3 %>
<%= f.text_area :message, placeholder: 'Enter your blab', class: 'form-control', data: { turbo_permanent: true, action: 'keydown->typing#typing keyup->typing#typingStopped' }, rows: 3 %>
<div id="typingHint" class="form-text" data-typing-target="display"></div>
</div>
<div class="actions my-2">
<%= f.submit class: "btn btn-primary" %>
</div>
<% end %>

We replace two lines, adding StimulusJS markup of <div class="form-group" and action: 'keydown->typing#typing keyup->typing#typingStopped' for the StimulusJS controller to listen for keydown and keyup events. We add turbo_permanent: true so that the turbo morph updates will not clear out the form’s current state (i.e., text that has been typed or bound javascript that StimulusJS is handling). Finally, the data-typing-target="display" will be used to update the message in the view when someone is typing.

Finally, we can add the StimulusJS controller to the app/javascript/controllers directory:

app/javascript/controllers/typing_controller.js
import { Controller } from "@hotwired/stimulus";
import consumer from "channels/consumer";
import debounce from "lodash.debounce";
export default class extends Controller {
static targets = ["display"];
initialize() {
this.typingStopped = debounce(this.typingStopped, 1000);
}
connect() {
this.subscription = consumer.subscriptions.create("TypingChannel", {
received: (data) => {
if (data.uid != this.userId) {
this.displayTarget.innerHTML =
data.action == "typing" ? `${data.user_email} is typing...` : "";
}
},
});
}
typing(_event) {
this.subscription.perform("typing");
}
typingStopped(_event) {
this.subscription.perform("typing_stopped");
}
get userId() {
return document.head.querySelector("meta[name=current-user-id]")?.content;
}
}

This controller has many moving parts; we’ll go through them by behavior.

First, we create a getter for the userId, which will get the current-user-id from the meta tag in the head of the document, which was added way earlier when we first modified the application.html.erb. This will be used later in the event filtering process.

Then, in the connect method, we create a subscription to the TypingChannel and then listen for the received event. The received happens when the broadcast is sent from the TypingChannel. We’ll come back to the if statement in a moment.

In the view, we used keydown->typing#typing to call the typing function when that event happens in the browser. The typing function then calls the perform method on the subscription to send the typing event to the TypingChannel. This event triggers the typing method in the TypingChannel to broadcast the typing event to the typing channel, which is handled by the received handler.

Additionally, we used keyup->typing#typingStopped in the view to call the typingStopped function. Here, we debounce the typingStopped function to only call it once every 1000 milliseconds or one second. This function will prevent the typingStopped method from being called too frequently and basically allow the controller to wait for the typing to be stopped. Using a debounce function is a common pattern in JavaScript to prevent a function from being called too frequently. We are using debounce from the lodash library to do this and was added in the import statement at the top of the file. The trick to getting the StimulusJS-based keydown event to use the lodash debounce is to set the controller’s typingStopped function in the initializer to itself inside the debounce function call.

The typingStopped function then calls the perform method on the subscription to broadcast the typing_stopped event to the TypingChannel much like the same round trip for typing.

To get the controller ready to display text, we added data-typing-target="display" in the view to display the typing indicator and then added a static targets = ["display"]; to allow this controller to interact with that element.

Now, finally getting back to the code inside the received handler, we check if the uid in the data object does not equal the userId from the meta tag. If the uid is not equal to the userId, then we update the displayTarget with the user’s email and a message that they are typing. Inside the innerHTML setter, we use a ternary operator to check if the action in the data object equals typing. If it is, we set the innerHTML to the user’s email and a message they are typing. If the action is not equal to typing, then we set the innerHTML to an empty string which clears the message in the browser.

Now, suppose you were to open two browsers (again, a current browser and an incognito would work!) and log into each user. In that case, you would see the typing indicator change in real time as one user types; the other would see the indicator on their browser!

Conclusion

Anyway, closing out these tutorials always feel weird. Hope you learned something from this and leave a comment with any questions or feedback!