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