DEV Community

Rob Race
Rob Race

Posted on • Originally published at robrace.dev

How to Build a Twitter Clone with Rails 8 and Turbo 8

Hotwire, StimulusJS, and Turbo have been out for the better part of half a decade, and there have been sweeping changes in the Turbo Broadcast and Stream patterns this past year. All of this gets bundled with Rails 8, plus other new features to make development even quicker, such as SolidQueue, SolidCache, and SolidCable. Two of those three make it dead simple to get this clone up and running super quick!

Note: I use Bootstrap when making something quick. You can throw an asset tag in the views/layout/application.html.erb or add Bootstrap to the rails --new command (but have yarn installed before running rails new). Additionally, I am using the --main flag to install Rails from the main branch in Github to get the currently unreleased but nearly ready Rails 8.

Without further ado, let's get started with a new app:

rails new blabber --main --css=boostrap
Enter fullscreen mode Exit fullscreen mode

Rails will do its thing:
Install the application.
Install the gems.
Process the Bootstrap installation.
Install the yarn packages.

Hotwire and Turbo are now part of Rails, so installing anything extra is unnecessary! Just fire up bin/dev and start writing some code.

Now, let's make a model to hold the data to clone a tweet in this Twitter clone called Blabber. All basic attributes:

rails g model Post username body:text likes_count:integer repost_count:integer
Enter fullscreen mode Exit fullscreen mode

To keep this closely resembling the previous article, we'll add the same validation in the Post model:

class Post < ApplicationRecord
 broadcasts_refreshes

 validates :body, length: { minimum: 1, maximum: 280 }
end
Enter fullscreen mode Exit fullscreen mode

One difference you see is the broadcasts_refreshes line. This line is part of Turbo 8, where the backend changes (here, changes to the model) are broadcast as a refresh event in Turbo, to be picked up by the turbo.js front end. Hidden behind the scenes is all the work Rails does to connect browsers, expecting that particular model instance to have updates and all the connecting parts such as ActiveJob, ActionCable, SolidQueue, and SolidCable.

We'll make the same few minor adjustments to the generated migration file to add some database-level defaults (and allow us to keep the code around Post creation simple):

class CreatePosts < ActiveRecord::Migration[6.0]
  def change
 create_table :posts do |t|
 t.string :username, default: 'Blabby'
 t.text :body
 t.integer :likes_count, default: 0
 t.integer :repost_count, default: 0

 t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Ok! Now, we're ready to run that migration!

rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Another change with this process of refreshes and streams is that our controller can now be unaware of the streams and does not need to have format-based responses or template files that closely resemble the same partials used for the non-dynamic part of the app.

class PostsController < ApplicationController

  def index
    @posts = Post.all.order(created_at: :desc)
    @post = Post.new
  end

  def create
    @post = Post.new(post_params)
 respond_to do |format|
      if @post.save
 redirect_to posts_path
      else
 render :index
      end
    end
  end

  def like
    Post.find_by(id: params[:post_id]).increment(:likes_count).save
 redirect_to posts_path
  end

  def repost
    Post.find_by(id: params[:post_id]).increment(:repost_count).save
 redirect_to posts_path
  end

  private

  def post_params
 params.require(:post).permit(:body)
  end
end
Enter fullscreen mode Exit fullscreen mode

Simple controller. The index action returns a list of posts to @post. create uses StrongParameters, creates a new Post, and redirects back to the index template. like and repost are similar, except they increment the respective count columns.

Let's wire up a few routes to match up to those controller actions. Yes, these aren't perfect RESTful routes, but 1) They work. 2) This is a 10-minute tutorial. 3) POST requests did not get the other browser window to update with the changes in my small app here. (If anyone knows why, I'd be happy to hear!)

Rails.application.routes.draw do
 resources :posts, only: %i[index create] do
 get 'like'
 get 'repost'
  end

 root to: 'posts#index'
end
Enter fullscreen mode Exit fullscreen mode

With a Model, Controller, and routes, we can put together some view templates. We can now add the "magic sauce" to the application layout to listen for refresh broadcasts.

<!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" />
    <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= turbo_refreshes_with method:
    :replace, 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 %>

    <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" />

    <%# Includes all stylesheet files in app/views/stylesheets %> <%=
    stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%=
    javascript_importmap_tags %> <%= stylesheet_link_tag "application",
    "data-turbo-track": "reload" %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The yield :head by the rails new command, and just above it, we added <%= turbo_refreshes_with method: :replace, scroll: :preserve %> to tell the application to listen for refreshes.

The first view template up is the 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' %> <%= render
  @posts %>
</div>
Enter fullscreen mode Exit fullscreen mode

The view is a relatively simple Rails template. It uses a render @posts method to render the collection of @posts using a posts/_post partial. The turbo_stream_from:posts hooks up the view layer to listen to the Post model's broadcasts.

The partial is some pretty standard singular object markup with some Bootstrap classes thrown in to make it look half-decent:

app/views/posts/_posts.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 at <%= post.created_at %> </small>
      <%= post.username %>
    </h5>
    <div class="card-text lead mb-2"><%= post.body %></div>
    <%= link_to post_repost_path(post), { class: 'card-link', data: {
    turbo_prefetch: false }} do %> Repost (<%= post.repost_count %>) <% end %>
    <%= link_to post_like_path(post), { class: 'card-link', data: {
    turbo_prefetch: false }} do %> Likes (<%= post.likes_count %>) <% end %>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Another turbo_stream command is used to listen for broadcasted updates and deletions, and the dom_id helper is used to create conventional Rails View IDs model_resource-id. In this case, it would look like posts_1, etc.

Up next is a straightforward Rails form:

<%= 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">
  <%= f.text_area :body, placeholder: 'Enter your blab', class: 'form-control',
  rows: 3 %>
</div>

<div class="actions"><%= f.submit class: "btn btn-primary" %></div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

If you remember the index.html.erb, another frame wrapped the form's render call. Thus, we included the dom_id to allow the controller's create action to enable Turbo to manage the streams' markup.

Now we'll head back to the controller to talk about the create action in depth:

def create
    @post = Post.new(post_params)
    respond_to do |format|
      if @post.save
        redirect_to posts_path
      else
        render :index
      end
    end
  end
Enter fullscreen mode Exit fullscreen mode

Previously, at this point, we'd discuss returning streams in the markup to be appended to the top-level frame, but Turbo 8's refreshes and broadcasts manage all of that without any extra changes.

At this point, you should have an app that you can open in two browsers, which should look like this!

Blabber Gif

There you go! If everything went well, this was the fastest Twitter clone tutorial yet and probably required the least lines of code! Time will tell how all of this will play well together and scale in complex real-world Rails applications, but for now, the reduction in developer complexity is a huge win!

Top comments (0)