This guide is part three of a three-part series on building a modern single-page application with Ruby on Rails and Inertia.js.
In the previous article, we built out CRUD functionality and explored how Rails and Inertia handle data interactions through forms, validations, and smooth data flow.
In this final chapter, we’ll focus on refining our app by reviewing Inertia requests, implementing nested routing and actions, generating JS routes, and adding serializers. These additions will simplify routing and give us more control over data passed to the frontend, completing our Rails-Inertia setup.
Source code: https://github.com/greybutton/rails_inertia_blog
1. Review Inertia Requests
Network Requests Overview
Inertia handles requests for us, managing both server responses and client-side navigation without full page reloads. Here’s a look at how Inertia manages data with some examples:
1. Viewing the Article List
– First Load: The initial page load is a standard document
request where the server delivers a complete HTML document, similar to a traditional server-rendered app.
– Subsequent Requests: Once the app is loaded, Inertia intercepts navigation requests. When you click to navigate within the app (e.g., “New Article” or “List Articles”), Inertia sends requests to the server, requesting only the necessary data to update the view.
2. Opening the New Article Page
When you click “New Article”, the request Inertia sends to the server looks like this:
{
"component": "articles/New",
"props": {
"article": {
"id": null,
"title": null,
"body": null,
"created_at": null,
"updated_at": null
},
"web_articles_path": "/web/articles"
},
"url": "/web/articles/new",
"version": null
}
Explanation:
– Component: Inertia identifies that it needs the articles/New
component.
– Props: The props
object contains data passed from the server to initialize the view. Here, it includes an empty article object (id
, title
, and body
set to null
) and the web_articles_path for form submission.
– URL: The request URL shows that we’re navigating to /web/articles/new
.
Inertia loads the New component with these props, dynamically displaying the form for a new article.
3. Returning to the Article List
When you click “List Articles”, the request payload changes as follows:
{
"component": "articles/Index",
"props": {
"articles": [
{
"id": 1,
"title": "Hello Rails",
"body": "I am on Rails!",
"created_at": "2024-10-28T18:57:53.319Z",
"updated_at": "2024-10-28T18:57:53.319Z",
"web_article_path": "/web/articles/1"
}
],
"new_web_article_path": "/web/articles/new"
},
"url": "/web/articles",
"version": null
}
Explanation:
– Component: Now, Inertia is loading the articles/Index
component for the list view.
– Props: The props
object here contains a list of articles, each with its attributes like title
, body
, created_at
, and updated_at
. Additionally, it includes the web_article_path link for each article, enabling easy navigation.
– URL: The request is made to /web/articles
, matching the URL for the article list page.
4. Using the Browser Back Button
If you use the browser’s back button to return to the list, the page doesn’t reload. Inertia caches each view, allowing for smooth transitions without redundant requests to the server.
In Summary
Inertia abstracts away the complexity of requests, handling data fetching, page transitions, and URL updates automatically. We simply specify:
– Which component to load.
– What props to pass to the component for rendering.
Inertia then takes care of sending the appropriate data to the server, managing history states, and seamlessly updating the view without a full page reload. This keeps our application fast and reactive, combining the best of server-side rendering with the benefits of a SPA (Single Page Application) experience.
2. Nested Routing and Actions
To add comments to an article, we’ll set up nested routes, a comments controller, and a form for submitting comments. Instead of adding comments as a separate prop, we’ll include them in the article’s comments attribute to illustrate embedding related data.
1. Create ApplicationController for Articles Namespace
This controller provides a shared resource_article
method for finding the parent Article
by article_id
, which will be used in the comments
controller.
# app/controllers/web/articles/application_controller.rb
class Web::Articles::ApplicationController < Web::ApplicationController
def resource_article
@resource_article ||= Article.find(params[:article_id])
end
end
2. Create CommentsController for Articles
This controller handles creating comments for a specific article. The create action finds the parent article via resource_article
and then creates the comment.
# app/controllers/web/articles/comments_controller.rb
class Web::Articles::CommentsController < Web::Articles::ApplicationController
def create
@comment = resource_article.comments.create(comment_params)
redirect_to web_article_path(resource_article)
end
private
def comment_params
params.require(:comment).permit(:commenter, :body)
end
end
3. Update ArticlesController
to Include Comments in Show
Action
Instead of adding comments as a separate prop, we embed them within the article object by using as_json
with include: :comments
. This demonstrates how to structure an article’s prop with associated data. Additionally, by including web_article_comments_path: web_article_comments_path(@article)
in the props, we provide a convenient URL for submitting new comments directly linked to the specific article.
# app/controllers/web/articles_controller.rb
class Web::ArticlesController < Web::ApplicationController
def show
@article = Article.find(params[:id])
render inertia: "articles/Show", props: {
article: @article.as_json(include: :comments),
edit_web_article_path: edit_web_article_path(@article),
web_article_path: web_article_path,
web_article_comments_path: web_article_comments_path(@article)
}
end
end
4. Update the Show
Component to Display and Add Comments
The Show
component now renders the list of comments and includes a form to add new comments.
// app/javascript/packs/web/articles/Show.jsx
import React from "react";
import { Link } from "@inertiajs/react";
import CommentForm from "./comments/_Form";
const Article = ({ article, edit_web_article_path, web_article_path, web_article_comments_path }) => {
const handleDestroy = (e) => {
if (!confirm("Are you sure?")) {
e.preventDefault();
}
};
const handleCommentSubmit = (form, options) => {
form.post(web_article_comments_path, options);
};
return (
<>
<h1>{article.title}</h1>
<p>{article.body}</p>
<ul>
<li>
<Link href={edit_web_article_path}>Edit Article</Link>
</li>
<li>
<Link
href={web_article_path}
onClick={handleDestroy}
method="delete"
as="button"
type="button"
>
Delete Article
</Link>
</li>
</ul>
<h2>Comments</h2>
<ul>
{article.comments.map((comment) => (
<li key={comment.id}>
{comment.commenter}: {comment.body}
</li>
))}
</ul>
<h2>Add a comment:</h2>
<CommentForm onSubmit={handleCommentSubmit} submitText="Add Comment" />
</>
);
};
export default Article;
5. Create the CommentForm
Component
The _Form.jsx
component handles form submission for comments, including form validation and resetting the form upon successful submission.
// app/javascript/packs/web/articles/comments/_Form.jsx
import React from "react";
import { useForm } from "@inertiajs/react";
const Form = ({ comment = {}, onSubmit, submitText }) => {
const form = useForm({
commenter: comment.commenter || "",
body: comment.body || "",
});
const { data, setData, processing, errors, reset } = form;
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(form, { onSuccess: () => reset() });
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
value={data.commenter}
onChange={(e) => setData("commenter", e.target.value)}
/>
{errors.commenter && <div>{errors.commenter}</div>}
</div>
<div>
<input
type="textarea"
value={data.body}
onChange={(e) => setData("body", e.target.value)}
/>
{errors.body && <div>{errors.body}</div>}
</div>
<button type="submit" disabled={processing}>
{submitText}
</button>
</form>
);
};
export default Form;
6. Update Routes to Include Nested Comments
Set up nested routes within the web/articles
namespace to make comments accessible under each article.
# config/routes.rb
namespace :web do
resources :articles do
scope module: :articles do
resources :comments
end
end
end
– Visit an article page to view existing comments.
– Use the “Add Comment” form to post a new comment and see it displayed immediately.
– To further explore, you can implement the delete functionality for comments as a next step.
This setup demonstrates how to nest resources in Inertia with Rails, passing data through props to maintain seamless interaction with the component hierarchy. Adding comments directly into the article
prop keeps the data structured, making it easier to work with associated models in the frontend.
3. Improvements
3.1 Generate JS Routes (Remove Route Passing)
To streamline the app and remove the need to manually pass routes from the backend, we’ll use the js-routes
gem to generate a single JavaScript file containing all Rails routes. This allows us to reference routes directly in the frontend without needing to modify backend actions every time we need a new route.
1. Add rake task lib/tasks/js_routes.rake
require "js-routes"
def generate_routes(root, **options)
routes_dir = Rails.root.join(root)
desc("Generate JS routes")
task(generate: :environment) do
FileUtils.mkdir_p(routes_dir)
routes_file_name = File.join(root, "routes.js")
JsRoutes.generate!(routes_file_name, **options)
end
end
namespace :js_routes do
generate_routes("packs/web", camel_case: true, include: [ /web/ ])
end
2. Install js-routes
gem
docker compose run --rm web bash -c "bundle add js-routes --version '~> 2.2'"
3. Generate routes.js
file
Use a custom Rake task to generate the routes.js file. This file will contain all Rails routes in a format suitable for JavaScript.
docker compose run --rm web bash -c "rake js_routes:generate"
This will generate app/javascript/packs/web/routes.js
, containing all routes with names in camelCase (following Rails naming conventions). Each route is defined as a function.
4. Refactor Frontend Components to Use JS Routes
After generating routes.js
, you can import the routes into your components and replace any previously passed paths.
Remove Route Props from Backend: In ArticlesController
, we can now eliminate all route-specific props from the show
, edit
, and other actions:
# app/controllers/web/articles_controller.rb
class Web::ArticlesController < Web::ApplicationController
def index
@articles = Article.all
render inertia: "articles/Index", props: {
articles: @articles,
}
end
def show
@article = Article.find(params[:id])
render inertia: "articles/Show", props: {
article: @article.as_json(include: :comments),
}
end
def new
@article = Article.new
render inertia: "articles/New", props: {
article: @article,
}
end
def edit
@article = Article.find(params[:id])
render inertia: "articles/Edit", props: {
article: @article,
}
end
end
Import Routes in Components: In your React components, import the generated routes instead of relying on backend-passed paths.
// app/javascript/packs/web/articles/Index.jsx
import React from "react";
import { Link } from "@inertiajs/react";
import { webArticlePath, newWebArticlePath } from "web/routes";
const Articles = ({ articles }) => (
<>
<h1>Articles</h1>
<ul>
{articles.map((article) => (
<li key={article.id}>
<Link href={webArticlePath(article)}>{article.title}</Link>
</li>
))}
</ul>
<Link href={newWebArticlePath()}>New Article</Link>
</>
);
// app/javascript/packs/web/articles/Show.jsx
import React from "react";
import { Link } from "@inertiajs/react";
import { editWebArticlePath, webArticlePath, webArticleCommentsPath } from "web/routes";
import CommentForm from "./comments/_Form";
const Article = ({ article }) => {
const handleDestroy = (e) => {
if (!confirm("Are you sure?")) {
e.preventDefault();
}
};
const handleCommentSubmit = (form, options) => {
form.post(webArticleCommentsPath(article), options);
};
return (
<>
<h1>{article.title}</h1>
<p>{article.body}</p>
<ul>
<li>
<Link href={editWebArticlePath(article)}>Edit Article</Link>
</li>
<li>
<Link
href={webArticlePath(article)}
onClick={handleDestroy}
method="delete"
as="button"
type="button"
>
Delete Article
</Link>
</li>
</ul>
<h2>Comments</h2>
<ul>
{article.comments.map((comment) => (
<li key={comment.id}>
{comment.commenter}: {comment.body}
</li>
))}
</ul>
<h2>Add a comment:</h2>
<CommentForm onSubmit={handleCommentSubmit} submitText="Add Comment" />
</>
);
};
export default Article;
// app/javascript/packs/web/articles/Edit.jsx
import React from "react";
import { webArticlePath } from "web/routes";
import Form from "./_Form";
const Edit = ({ article }) => {
const handleSubmit = (form) => {
form.put(webArticlePath(article));
};
return (
<>
<h1>Edit Article</h1>
<Form
article={article}
onSubmit={handleSubmit}
submitText="Update Article"
/>
</>
);
};
export default Edit;
// app/javascript/packs/web/articles/New.jsx
import React from "react";
import { Link } from "@inertiajs/react";
import { webArticlesPath } from "web/routes";
import Form from "./_Form";
const New = ({ article }) => {
const handleSubmit = (form) => {
form.post(webArticlesPath());
};
return (
<>
<h1>New Article</h1>
<Form
article={article}
onSubmit={handleSubmit}
submitText="Create Article"
/>
<Link href={webArticlesPath()}>List Articles</Link>
</>
);
};
export default New;
5. Regenerate Routes for New Paths
Whenever you add a new route in Rails, run rake js_routes:generate
to update routes.js
so the new routes become available in the frontend.
Why This Change Improves the Code
– Eliminates Redundant Path Passing: By centralizing route definitions in routes.js
, we no longer need to pass paths from backend actions, reducing repetition and improving readability.
– Keeps Routing Logic Consistent: Using Rails’ routes directly in JavaScript ensures the frontend adheres to backend routing conventions, even as routes change or expand.
– Reduces Controller Changes: This approach minimizes backend modifications whenever we add or change paths, making future maintenance more efficient.
This refactoring allows the frontend to directly access any route, significantly improving maintainability and scalability as the application grows.
3.2 Adding Serializers
To control which fields are sent in responses and simplify data structure, we’re introducing serializers with the active_model_serializers
gem. This allows us to customize responses for each view, selectively including only the necessary attributes and relationships.
Why Use Serializers?
Without serializers, our responses include all fields from the database, which may expose unnecessary data. With serializers, we can tailor each response, providing only the fields we want. For example, we’ll exclude fields like created_at
and updated_at
in certain views, streamlining the data sent to the frontend.
1. Add active_model_serializers
gem