Skip to content

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.

Terminal window
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.

Terminal window
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
end
end

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

Terminal window
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:

Terminal window
bundle install
bin/rails generate inertia:install

The installer is interactive and will ask a few questions:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

app/javascript/types/Post.ts
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:

app/javascript/components/PostForm.tsx
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:

app/javascript/components/PostList.tsx
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:

app/javascript/components/PostItem.tsx
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):

Terminal window
bin/rails g channel PostsChannel

Next, we’ll add the subscribed method to the PostsChannel class to stream from the PostsChannel:

app/channels/posts_channel.rb
class PostsChannel < ApplicationCable::Channel
def subscribed
stream_from "PostsChannel"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end

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

Lastly, for the backend, we’ll add a broadcast to the Post model to broadcast the post to the PostsChannel:

app/models/post.rb
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
})
end
end

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.

Terminal window
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.

app/javascript/pages/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!