How to Build a Twitter Clone with Rails 8 Inertia and React
While I have written a bunch lately about using the “Rails way” to build applications, I have been keeping an eye on InertiaJS via internet friends, especially those in the Laravel space.
InertiaJS is a way to build single-page applications (SPAs) without creating an API. Specifically, using the inertia-rails
gem, you can easily make a Rails application that uses InertiaJS to add frontend libraries.
rails new blabber --main --css=bootstrap
Rails will do its thing: install the application, install the gems, and process the Bootstrap and dependencies installs.
Next, we can review the model, controller, and routing setup before proceeding to the InertiaJS and React components.
rails g model Post username body:text likes_count:integer repost_count:integer
To keep this closely resembling the formerly bird app that we are cloning, we’ll add the same validation in the Post
model:
class Post < ApplicationRecord validates :body, length: { minimum: 1, maximum: 280 }end
We’ll make a few small adjustments to the generated migration file to add some database-level defaults (and allows 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 endend
Ok! Now, we’re ready to run that migration!
rails db:migrate
Before going forward with the controller, now that you have the database and model set, we should install the inertia-rails
gem as it will dictate some differences in the controller and view layer (through frontend components).:
gem 'inertia_rails'
With the gem installed, we can run the installer:
bundle installbin/rails generate inertia:install
The installer is interactive and will ask a few questions:
- Do you want to install Vite Ruby? This library is the build tool for handling the front-end assets through InertiaJS. I selected yes, and I assume if you choose no, you would already be running some other front-end build process.
- Would you like to use TypeScript? I use TypeScript in my projects, so I selected yes. If you are not familiar with TypeScript, you can choose no.
- What framework do you want to use with Inertia? [react, vue, svelte4, svelte] I selected React, as that is what we are using in this tutorial.
- Would you like to use Tailwind CSS? I selected no, as we are using Bootstrap in this tutorial.
The installer will install all those dependencies and set up the Rails application to use InertiaJS. It will also create a demo controller and “Page” component to show you how to use InertiaJS. Those files are found at app/controllers/inertia_example_controller.rb
and app/javascript/pages/InertiaExample.tsx
.
With everything added, you should be able to run bin/dev
and see the Rails server and Vite server running. You can visit http://localhost:3000/inertia_example
to see the demo page.
With the inertiaJS install out of the way, we can move on to the controller and corresponding view templates!
class PostsController < ApplicationController
def index @posts = Post.all.order(created_at: :desc) render inertia: 'Posts', props: { posts: @posts.as_json } end
def create post = Post.new(post_params)
if post.save redirect_to posts_path else redirect_to new_post_path, inertia: { errors: post.errors } 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.expect(post: [:body]) end
end
Simple controller. The index
action returns a list of posts to @post
and renders a Posts
“page” in the front-end, which we will add shortly. create
creates a new Post and redirects back to the Post
component or the form if it fails. InertiaJS handles the routing in the front end to ensure the correct React rendering is happening. 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.
Rails.application.routes.draw do get 'inertia-example', to: 'inertia_example#index' # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
# Defines the root path route ("/") resources :posts, only: %i[index create] do get 'like' get 'repost' end
root "posts#index"end
The inertia install modified the application.html.erb
file to include the Inertia
and Vite
tags. To make sure everything is copasetic, here is the entire file:
<!DOCTYPE html><html> <head> <title inertia><%= 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 %>
<%= 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">
<%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= vite_react_refresh_tag %> <%= vite_client_tag %> <%= vite_typescript_tag "inertia" %> <%= inertia_ssr_head %> <%= vite_javascript_tag 'application' %> <!-- If using a TypeScript entrypoint file: vite_typescript_tag 'application'
If using a .jsx or .tsx entrypoint, add the extension: vite_javascript_tag 'application.jsx'
Visit the guide for more information: https://vite-ruby.netlify.app/guide/rails -->
</head>
<body> <%= yield %> <%= console %> </body></html>
This covers most of the initial changes needed on the traditional Rails side, and we can now start adding the React components.
import { Head } from "@inertiajs/react";import { PostForm } from "../components/PostForm";import { PostList } from "../components/PostList";import { Post } from "../types/Post";
export default function Posts({ posts }: { posts: Post[] }) { return ( <> <Head title="Posts" />
<div className="container"> <h1>Blabber</h1> <h4>A Rails, Inertia, React demo!</h4> <PostForm /> <PostList posts={posts} /> </div> </> );}
A great thing about using React is the composability of the components that you would typically not get with ERB templates. The Posts
component is a wrapper for the PostForm
and PostList
components. You may also notice that we’re pulling in a Post
type to the PostList
component. This import is a TypeScript type that we will define in a moment. Lastly, as mentioned before, inertiaJS will handle routing and rendering of these components. Thus, they provide a Head
component for the page’s title.
For quick reference, here is the Post
type:
export interface Post { id: number; username: string; body: string; repost_count: number; likes_count: number; created_at: string;}
The PostForm
component is a simple form that will allow the user to create a new post:
import { useForm } from "@inertiajs/react";
export const PostForm = () => { const { data, setData, post, processing, errors, reset } = useForm({ body: "", });
function submit(e: React.FormEvent) { e.preventDefault(); post("/posts", { onSuccess: () => reset("body"), }); }
return ( <form className="my-4" onSubmit={submit}> <div className="mb-3"> <textarea className="form-control" rows={3} value={data.body} placeholder="Enter Your Blab" onChange={(e) => setData("body", e.target.value)} /> {errors.body && <div>{errors.body}</div>} </div> <div className="mb-3"> <button type="submit" className="btn btn-primary" disabled={processing}> Blab </button> </div> </form> );};
InertiaJS does a lot of the heavy lifting for you, including form handling. The useForm
hook is a custom hook that InertiaJS provides to handle form data, submission, and errors. The submit
function is a simple function that will post the form data to the create
action in the PostsController
. The form is a straightforward Bootstrap form with a textarea and a submit button.
The PostList
component is a simple list of posts:
import { useEffect, useState } from "react";
import { Post } from "../types/Post";
import { PostItem } from "../components/PostItem";
export const PostList = ({ posts }: { posts: Post[] }) => {
return ( <> {posts.map((post: Post) => ( <PostItem key={post.id} post={post} /> ))} </> );};
The PostList
is relatively small component as it maps over the posts
prop and renders a PostItem
component for each post.
Next up is the aforementioned PostItem
component:
import { Post } from "../types/Post";import { Link } from "@inertiajs/react";
export const PostItem = ({ post }: { post: Post }) => { return ( <div className="card mb-2" id={`post_${post.id}`}> <div className="card-body"> <h5 className="card-title text-muted"> <small className="float-right">Posted at {post.created_at}</small> {post.username} </h5> <div className="card-text lead mb-2">{post.body}</div> <Link href={`/posts/${post.id}/repost`} className="card-link"> Repost ({post.repost_count}) </Link> <Link href={`/posts/${post.id}/like`} className="card-link"> Like ({post.likes_count}) </Link> </div> </div> );};
The PostItem
component is a simple Bootstrap card that displays the post’s username, body, and creation date. It also includes two links to repost and like the post. The links are InertiaJS Link
components that will handle the routing and rendering of the repost and like actions.
Alright! That is all the React components we need to build the clone. If you head to your Rails app, you should be able to see the posts form and a list of posts. You can create a new post, which will appear in the list. Plus, you can like and repost posts.
However, if you have followed along with any of the other clone tutorials, you know that we are missing one key feature: real-time updates. In this tutorial, we will use ActionCable
to add real-time updates and a npm package called use-action-cable
to handle the front-end subscription.
First, we can add the ActionCable side to get the backend ready for the front-end subscription. We’ll start by adding a new channel to handle the posts (which will, in turn, add a few new default files as well):
bin/rails g channel PostsChannel
Next, we’ll add the subscribed
method to the PostsChannel
class to stream from the PostsChannel
:
class PostsChannel < ApplicationCable::Channel def subscribed stream_from "PostsChannel" end
def unsubscribed # Any cleanup needed when channel is unsubscribed endend
Also, we’ll edit the Connection
file to identify the Cable connection with the browser session ID:
module ApplicationCable class Connection < ActionCable::Connection::Base identified_by :session_id
def connect self.session_id = request.session.id end endend
Lastly, for the backend, we’ll add a broadcast to the Post
model to broadcast the post to the PostsChannel
:
class Post < ApplicationRecord validates :body, length: { minimum: 1, maximum: 280 }
after_commit :broadcast, on: [:create, :update, :destroy]
def broadcast ActionCable.server.broadcast('PostsChannel', { posts: Post.all.order(created_at: :desc).as_json }) endend
This change will broadcast the posts to the PostsChannel
after they are created, updated, or destroyed. Yes, this is ultra simplistic by returning all of the posts, but it will work for this tutorial. You would want to scope this down in a production application.
Alright, that’s it for the ActionCable backend! Next up is the React
side of the application.
We will use a more current fork of the original use-action-cable
package. This package will allow us to subscribe to the PostsChannel
and handle the incoming posts.
yarn add react-use-action-cable-ts
This package utilizes a few custom hooks to add a connection to an ActionCable channel easily. We’ll use the useActionCable
and useChannel
hooks to subscribe to the PostsChannel
and handle the incoming posts.
import { useEffect, useState } from "react";
import { Post } from "../types/Post";
import { useActionCable, useChannel } from "react-use-action-cable-ts";import { PostItem } from "../components/PostItem";
export const PostList = ({ posts }: { posts: Post[] }) => { const [postData, setPostData] = useState<Post[]>(posts); const { actionCable } = useActionCable("ws://127.0.0.1:3000/cable"); const { subscribe, unsubscribe } = useChannel(actionCable, { receiveCamelCase: false, verbose: false, sendSnakeCase: false, });
useEffect(() => { subscribe( { channel: "PostsChannel", }, { received: (data: unknown) => { const cableData = data as { posts: Post[] }; setPostData(cableData.posts); }, } ); return () => { unsubscribe(); }; }, []); return ( <> {postData.map((post: Post) => ( <PostItem key={post.id} post={post} /> ))} </> );};
The changes here will start with adding useState
and useEffect
to the PostList
component. These hooks will allow us to set the incoming posts from the PostsChannel
broadcast. We’ll also add the useActionCable
and useChannel
hooks to handle connecting to the PostsChannel
. We will connect to "ws://127.0.0.1:3000/cable"
to access the local ActionCable server output. Still, you should utilize another method of getting the current host if you put this code in Production. The hook returns an actionCable
object that we can use to subscribe to the PostsChannel
.
The useChannel
hook will use that actionCable
object to set up a function to subscribe to the PostsChannel
and set a few settings for the channel. Specifically, the receiveCamelCase
and sendSnakeCase
settings are set to false
to match the Rails backend. The verbose
setting is set to false
to reduce the console output.
The useEffect
React hook will take the subscribe
function from the useChannel
hook and subscribe to the PostsChannel
. The subscribe
function takes two arguments: the channel name and an object with a received
function that will handle the incoming data. The received
function will set the incoming posts to the postData
state. The useEffect
hook will also return an unsubscribe
function that will unsubscribe from the PostsChannel
when the component is unmounted.
With all these changes, you should have a fully functioning clone that uses InertiaJS and React for the front end and ActionCable for real-time updates. You can create, like, and repost posts, which will be updated in real-time across all clients.
You can head back to https://localhost:3000 and see the posts in real-time now!
There you go! I bet that with a fast enough system to work through the Rails, gem, and Javascript package installs, you could complete this tutorial in less than 10 minutes!