【実装編】React・TypeScriptとRails APIで投稿サービスを作る(認証なし)

投稿詳細ページ

今回の記事では、React・TypeScriptとRails APIを用いて、投稿サービスを実装します。

Rails APIで投稿データを管理するAPIを作成し、ReactからAPIを呼び出して、投稿の作成・一覧表示・詳細表示・編集・削除(CRUD機能)を実装します。

Rails APIについて基本から知りたい方は、「Railsとは|MVCの基本とRails APIの全体像をわかりやすく解説」を先に読むのがおすすめです。

この記事の1章から5章まではRails、6章から8章まではReact・TypeScriptの実装について解説します。

※ 投稿サービス全体の開発手順については、「【手順解説】React・TypeScriptとRails APIで投稿サービスを作る(認証なし)」を参考にしてください。

目次 非表示

1. [Rails]データベースにポストデータを追加する

まずは、投稿データを保存するためのデータベースを作成します。

この章では、Postモデル用のテーブルを作成し、マイグレーションを実行してデータベースへ反映します。

1-1. ポストデータモデルの確認

以下のポストデータモデルを参考に、postsテーブルを作成します。

※ Ruby on Railsでは、idは主キーとして、created_atとupdated_atはタイムスタンプとして自動的に追加されるため、省略しています。

1-2. 「rails db:create」コマンドで、データベースを作成する

以下のコマンドを実行し、データベースを作成します。

$ docker compose exec app rails db:create

※ すでにデータベースを作成している場合は、この手順は不要です。

1-3. [Postモデル]マイグレーションファイルを作成・編集する

rails generate model コマンドを使用すると、モデルとマイグレーションファイルを同時に生成できます。今回は処理の流れを理解しやすくするため、それぞれ個別に作成します。

以下のコマンドを実行し、マイグレーションファイルを生成します。

$ docker compose exec app rails generate migration CreatePosts title:string content:text username:string

コマンドを実行すると、以下のようなマイグレーションファイルが生成されます。

【db/migrate/20260725075737_create_posts.rb】

class CreatePosts < ActiveRecord::Migration[8.0]
  def change
    create_table :posts do |t|
      t.string :title
      t.text :content
      t.string :username

      t.timestamps
    end
  end
end

続いて、マイグレーションファイルを以下のように編集します。

【db/migrate/20260725075737_create_posts.rb】

class CreatePosts < ActiveRecord::Migration[8.0]
  def change
    create_table :posts do |t|
      t.string :title, limit: 255, null: false
      t.text :content, limit: 5000
      t.string :username, null: false

      t.timestamps
    end
  end
end

1-4. 「rails db:migrate」コマンドでマイグレーションを反映する

以下のコマンドを実行し、マイグレーションをデータベースへ反映します。

$ docker compose exec app rails db:migrate

実行後、「db/schema.rb」が以下のように更新されていれば完了です。

【db/schema.rb(変更前)】
ActiveRecord::Schema[8.0].define(version: 0) do
  # These are extensions that must be enabled in order to support this database
  enable_extension "pg_catalog.plpgsql"

end
【db/schema.rb(変更後)】
ActiveRecord::Schema[8.0].define(version: 2026_07_25_075737) do
  # These are extensions that must be enabled in order to support this database
  enable_extension "pg_catalog.plpgsql"

  create_table "posts", force: :cascade do |t|
    t.string "title", limit: 255, null: false
    t.text "content"
    t.string "username", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end
end

2. [Rails]コントローラを作成する

次に、投稿データを操作するためのコントローラを作成します。

この章では、API用のコントローラを作成し、投稿の作成・一覧表示・詳細表示・編集・削除(CRUD)の処理を実装します。

コントローラについて詳しく知りたい方は、「Controllerとは|Rails APIにおけるControllerの役割と書き方をわかりやすく解説」を参考にしてください。

2-1. コントローラをモジュール化するためのテンプレートを作成

lib/templates/rails/controller/ 配下に controller.rb を作成します。

このテンプレートを用意しておくことで、rails generate controller コマンド実行時に、自動的にテンプレートが読み込まれます。

今回は、APIのバージョン管理を行いやすくするため、V1モジュール配下にコントローラを作成します。

【lib/templates/rails/controller/controller.rb】
module V1
  class <%= class_name.gsub('V1::', '') %>Controller < ApiController
  end
end

2-2. ApiControllerを作成

ApiControllerは、すべてのAPIコントローラで共通して利用する基底クラスです。

実際の開発では、認証や認可、エラーハンドリングなどの共通処理を記述します。

今回は、最低限のクラスだけを作成します。

【app/controllers/v1/api_controller.rb】
module V1
  class ApiController < ApplicationController
  end
end

2-3. 「rails generate controller」コマンドでPostsコントローラを作成する

以下のコマンドを実行し、Postsコントローラを生成します。

$ docker compose exec app rails generate controller v1/posts

テンプレートが自動で読み込まれない場合は、以下のように明示的に指定してください。

$ docker compose exec app rails generate controller v1/posts --template=lib/templates/rails/controller/controller.rb

コマンドを実行すると、以下の画像のようにPostsコントローラが生成されます。

2-4. PostsコントローラにCRUD処理を実装する

投稿の一覧取得・詳細取得・作成・更新・削除(CRUD)の処理を実装します。

【app/controllers/v1/posts_controller.rb】

module V1
  class PostsController < ApiController
    def index
      posts = Post.all
      render json: posts
    end

    def show
      post = Post.find(params[:id])
      render json: post
    end

    def create
      post = Post.new(post_params)

      if post.save
        render json: post, status: :created
      else
        render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
      end
    end

    def update
      post = Post.find(params[:id])

      if post.update(post_params)
        render json: post, status: :ok
      else
        render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
      end
    end

    def destroy
      post = Post.find(params[:id])
      post.destroy
      render json: {}, status: :no_content
    end

    private

    def post_params
      params.require(:post).permit(:title, :content, :username)
    end
  end
end

これでコントローラの作成は完了です。

3. [Rails]ルーティングを設定する

続いて、作成したコントローラを利用できるよう、ルーティングを設定します。

この章では、投稿の一覧取得・詳細取得・作成・更新・削除(CRUD)に必要なルーティングを定義します。

ルーティングは、受け取ったリクエストを、どのコントローラのどのアクションで処理するかを決める仕組みです。

Railsのルーティングやresourcesについて詳しく知りたい方は、「Railsのルーティングとは|routes.rbとresourcesの基本をRails APIでわかりやすく解説」を参考にしてください。

3-1. 投稿APIのルーティングを設定する

config/routes.rbを以下のように編集します。

Rails.application.routes.draw do
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
  namespace :v1 do
    # すべてのアクションをまとめて定義する場合
    resources :posts

    # 必要なアクションだけを定義する場合
    # resources :posts, only: [:index, :show, :create]

    # アクションを1つずつ定義する場合
    # get '/posts', to: 'posts#index'
    # post '/posts', to: 'posts#create'
    # get '/posts/:id', to: 'posts#show'
    # patch '/posts/:id', to: 'posts#update'
    # delete '/posts/:id', to: 'posts#destroy'
  end
end

resources :postsを使用すると、投稿機能に必要なRESTfulルーティングをまとめて定義できます。

必要なアクションだけを定義したい場合はonlyオプションを、ルーティングを1つずつ定義したい場合はgetpostなどを使用します。

namespace :v1を使用することで、URLに「/v1」が付与され、V1::PostsControllerへルーティングされます。

これでルーティングの設定は完了です。

4. [Rails]モデルを作成する

続いて、投稿データを管理するモデルを作成します。

この章では、Postモデルを作成し、投稿データのバリデーションを設定します。

モデルは、データベースとのやり取りや、データに関する処理を担当するクラスです。

Modelの役割や書き方について詳しく知りたい方は、「Modelとは|Rails APIにおけるModelの役割と書き方をわかりやすく解説」を参考にしてください。

4-1. 「rails generate model」コマンドでPostモデルを作成する

まずは、以下のコマンドを実行し、Postモデルを作成します。

$ docker compose exec app rails generate model Post --skip

今回は、1章ですでにマイグレーションファイルを作成しているため、--skipオプションを付けてマイグレーションファイルの生成をスキップします。

4-2. Postモデルにバリデーションを追加する

投稿データは、タイトル作成者名を必須とし、本文は任意とします。

class Post < ApplicationRecord
  validates :title, presence: true
  validates :username, presence: true
end

これでPostモデルの作成は完了です。

5. [Rails]rack-corsを設定する

続いて、ReactとRails APIが通信できるように、rack-corsを設定します。

この章では、rack-corsを導入し、ReactからRails APIへのアクセスを許可します。

5-1. rack-corsをインストールする

ブラウザでは、セキュリティ上の理由から、異なるURL間の通信は制限されています。

rack-corsは、Rails APIが特定のURLからのリクエストを許可するためのgemです。

例えば、Rails APIが「http://localhost:3000」、Reactが「http://localhost:5173」で動作している場合、Rails API側でReactのURLを許可することで、API通信が行えるようになります。

まずは、Gemfilerack-corsのコメントアウトを解除します。

【[更新前]Gemfile】

・・・省略・・・

# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
# gem "rack-cors"

・・・省略・・・
【[更新後]Gemfile】

・・・省略・・・

# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
gem "rack-cors"

・・・省略・・・

その後、以下のコマンドを実行してgemをインストールします。

# Dockerコンテナを起動している時
$ docker compose exec app bundle install

# Dockerコンテナを起動していない時
$ docker compose run app bundle install

5-2. Reactからのアクセスを許可する

続いて、config/initializers/cors.rbを編集し、Reactからのアクセスを許可します。

デフォルトでは、config/initializers/cors.rbはコメントアウトされています。

【[更新前]config/initializers/cors.rb】
# Rails.application.config.middleware.insert_before 0, Rack::Cors do
#   allow do
#     origins "example.com"
#
#     resource "*",
#       headers: :any,
#       methods: [:get, :post, :put, :patch, :delete, :options, :head]
#   end
# end

以下のように設定を変更します。

  1. コードのコメントアウトを解除
  2. 「origins “example.com”」を「origins ENV[‘FRONTEND_ORIGIN’]」に変更
  3. 「.env」ファイルに、「FRONTEND_ORIGIN」の変数と値をセットする。

【[更新後]config/initializers/cors.rb】
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins ENV['FRONTEND_ORIGIN']

    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

続いて、.envファイルにFRONTEND_ORIGINを設定します。

※ すでに設定済みの場合は、この手順は不要です。

【.env】
DB_USER=root
DB_PASSWORD=12345678
DB_HOST=db
DB_NAME_DEVELOPMENT=my_app_development
DB_NAME_TEST=my_app_test
DB_PORT=5432
RAILS_ENV=development

# 以下をセット
FRONTEND_ORIGIN=http://localhost:5173

最後に、設定を反映するためDockerコンテナを再ビルドします。

  1. docker compose downを実行し、コンテナを終了する
  2. docker compose buildを実行し、ビルドする
  3. docker compose up -dを実行し、コンテナを立ち上げる

これでRails API側の設定は完了です。

6. [React]API通信用関数を作成する

続いて、Rails APIと通信するためのAPI通信用関数を作成します。

この章では、axiosを導入し、投稿データの取得・作成・更新・削除を行うAPI通信用関数を実装します。

※ 本記事ではTypeScriptを使用していますが、TypeScriptの詳しい文法については扱わず、ReactとRails APIの実装を中心に解説します。

6-1. axios、camelcase-keys、snakecase-keysをインストールする

Rails APIと通信するために、以下の3つのパッケージを使用します。

  • axios:API通信を簡単に行うためのライブラリ
  • camelcase-keys:レスポンスのキーをキャメルケースへ変換するライブラリ
  • snakecase-keys:リクエストのキーをスネークケースへ変換するライブラリ

ReactではuserNameのようなキャメルケース、Railsではuser_nameのようなスネークケースを使用することが一般的です。

そのため、API通信にはaxiosを使用し、送信時はsnakecase-keys、受信時はcamelcase-keysを使用してキーの形式を自動で変換します。

以下のコマンドを実行し、3つのパッケージをインストールしてください。

$ docker compose exec front pnpm add axios camelcase-keys snakecase-keys
【package.json】
・・・省略・・・

  "dependencies": {
    ・・・省略・・・

    "axios": "^1.18.1",
    "camelcase-keys": "^10.0.2",
    "snakecase-keys": "^9.0.2"

    ・・・省略・・・
  },

・・・省略・・・

6-2. APIのURLを設定する

続いて、開発環境で使用するAPIの接続先を環境変数に設定します。

プロジェクトのルートに.env.developmentファイルを作成し、以下を追加してください。

VITE_API_URL="http://localhost:3000/v1"

Viteの開発サーバーはデフォルトでdevelopmentモードで起動するため、開発時には.env.developmentに設定した環境変数が読み込まれます。

また、VITE_から始まる環境変数は、以下のようにimport.meta.envから参照できます。

import.meta.env.VITE_API_URL

.env.developmentを作成・変更した場合は、設定を反映するために開発サーバーを再起動してください。

6-3. API通信用関数を作成する

続いて、app/lib/api.tsを作成し、投稿データの取得・作成・更新・削除を行うAPI通信用関数を定義します。

このファイルでは、axiosを使用してRails APIと通信し、送信時にはsnakecase-keys、受信時にはcamelcase-keysを使用してキーの形式を変換します。

【app/lib/api.ts】
import axios from "axios";
import camelcaseKeys from "camelcase-keys";
import snakecaseKeys from "snakecase-keys";

type PostId = string | number;
type PostParams = Record<string, unknown>;

axios.defaults.baseURL = import.meta.env.VITE_API_URL;
axios.defaults.headers.common["Content-Type"] = "application/json";

// posts
export const createPost = async (params: PostParams) =>
  axios.post("/posts", snakecaseKeys(params));

export const getPost = async (params: PostId) =>
  axios({
    method: "get",
    url: `/posts/${params}`,
  }).then((response) => camelcaseKeys(response, { deep: true }));

export const updatePost = async (params: PostId, data: PostParams) =>
  axios({
    method: "put",
    url: `/posts/${params}`,
    data: snakecaseKeys(data),
  }).then((response) => camelcaseKeys(response, { deep: true }));

export const deletePost = async (params: PostId) =>
  axios({
    method: "delete",
    url: `/posts/${params}`,
  });

export const getPosts = async () =>
  axios({
    method: "get",
    url: "/posts",
  }).then((response) => camelcaseKeys(response, { deep: true }));

以降は、このAPI通信用関数を各ページから呼び出して、投稿データの取得・作成・更新・削除を行います。

これでAPI通信用関数の作成は完了です。

7. [React]ルーティングを設定する

続いて、各ページへ移動できるよう、ルーティングを設定します。

この章では、投稿一覧・投稿詳細・投稿作成・投稿編集ページへ遷移するためのルーティングを定義します。

7-1. React Routerでルーティングを設定する

React Routerでは、app/routes.tsでルーティングの設定を行います。

プロジェクト作成時は、次のような内容になっています。

【app/routes.ts】
import { type RouteConfig, index } from "@react-router/dev/routes";

export default [index("routes/home.tsx")] satisfies RouteConfig;

index()はインデックスルート(トップページ)を定義するためのヘルパーです。現在のアプリケーションでは、/へアクセスするとroutes/home.tsxが表示されます。

今回は、この初期ルーティングを投稿サービス用のルーティングへ変更します。

app/routes.tsを以下のように編集してください。

【app/routes.ts】
import {
  type RouteConfig,
  index,
  route,
} from "@react-router/dev/routes";

export default [
  index("routes/posts/posts.tsx"),
  route("posts/create", "routes/posts/create-post.tsx"),
  route("posts/:id", "routes/posts/post.tsx"),
  route("posts/:id/edit", "routes/posts/edit-post.tsx"),
] satisfies RouteConfig;

route()は、URLと表示するページを対応付けるためのヘルパーです。

これでルーティングの設定は完了です。

現時点では、posts.tsxpost.tsxなどの各ページをまだ作成していないため、これらのURLへアクセスしてもページを表示できません。次の章で各ページを順番に作成していきます。

8. [React]UI(ページ)を構築する

続いて、投稿サービスのUIを構築します。

この章では、投稿作成・投稿一覧・投稿詳細・投稿編集ページを作成し、それぞれのページからRails APIを呼び出して投稿データを操作できるようにします。

今回は、コンポーネントを細かく分割せず、ページ単位でUIを実装します。

最終的なフォルダ構成は、以下のとおりです。

app/
├── lib/
│   └── api.ts
└── routes/
    └── posts/
        ├── create-post.tsx
        ├── edit-post.tsx
        ├── post.tsx
        └── posts.tsx

※ 今回はシンプルな構成にするため、カスタムHooksや共通コンポーネントは作成しません。実際の開発では、API通信や状態管理などを別ファイルへ分割することもあります。

※ 今回は、画面を見やすくするため、Tailwind CSSを使用して簡単なスタイリングも行っています。また、本記事ではTypeScriptやTailwind CSSの詳しい文法・使い方には深入りせず、ReactとRails APIの連携を中心に学んでいきます。

8-1. 投稿作成ページを作成する

まずは、投稿を新しく作成する投稿フォームを作成し、入力したデータをRails APIへ送信できるようにします。

app/routes/posts/create-post.tsxを作成し、以下のように記述します。

【app/routes/posts/create-post.tsx】
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { createPost } from "../../lib/api";

export default function CreatePost() {
  const navigate = useNavigate();

  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [username, setUsername] = useState("");
  const [error, setError] = useState<unknown>(null);
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    const newPost = {
      title,
      content,
      username,
    };

    try {
      setIsSubmitting(true);
      setError(null);

      await createPost(newPost);
      navigate("/");
    } catch (error) {
      setError(error);
      setIsSubmitting(false);
    }
  };

  return (
    <main className="mx-auto max-w-2xl p-6">
      <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
        投稿作成
      </h2>

      <p className="mt-4">
        <Link
          to="/"
          className="text-sm text-blue-600 hover:underline dark:text-blue-400"
        >
          投稿一覧へ戻る
        </Link>
      </p>

      {!!error && (
        <p className="mt-4 text-red-600 dark:text-red-400">
          投稿の作成に失敗しました。
        </p>
      )}

      <form onSubmit={handleSubmit} className="mt-6 space-y-4">
        <div>
          <label
            htmlFor="title"
            className="block text-sm font-medium text-gray-700 dark:text-gray-300"
          >
            タイトル
          </label>
          <input
            id="title"
            type="text"
            value={title}
            onChange={(event) => setTitle(event.target.value)}
            required
            className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
          />
        </div>

        <div>
          <label
            htmlFor="content"
            className="block text-sm font-medium text-gray-700 dark:text-gray-300"
          >
            本文
          </label>
          <textarea
            id="content"
            value={content}
            onChange={(event) => setContent(event.target.value)}
            required
            rows={6}
            className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
          />
        </div>

        <div>
          <label
            htmlFor="username"
            className="block text-sm font-medium text-gray-700 dark:text-gray-300"
          >
            ユーザー名
          </label>
          <input
            id="username"
            type="text"
            value={username}
            onChange={(event) => setUsername(event.target.value)}
            required
            className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
          />
        </div>

        <button
          type="submit"
          disabled={isSubmitting}
          className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
        >
          {isSubmitting ? "作成中..." : "作成"}
        </button>
      </form>
    </main>
  );
}

8-2. 投稿一覧ページを作成する

続いて、投稿一覧ページを作成します。

Rails APIから投稿データを取得し、一覧として表示します。

【app/routes/posts/posts.tsx】
import { useEffect, useState } from "react";
import { Link } from "react-router";
import { getPosts } from "../../lib/api";

interface Post {
  id: number;
  title: string;
}

export default function Posts() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<unknown>(null);

  useEffect(() => {
    const fetchPosts = async () => {
      try {
        const response = await getPosts();
        setPosts(response.data);
      } catch (error) {
        setError(error);
      } finally {
        setIsLoading(false);
      }
    };

    fetchPosts();
  }, []);

  if (isLoading) {
    return <p>読み込み中...</p>;
  }

  if (error) {
    return <p>投稿の取得に失敗しました。</p>;
  }

  return (
    <main className="mx-auto max-w-2xl p-6">
      <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
        投稿一覧
      </h2>

      <p className="mt-4">
        <Link
          to="/posts/create"
          className="text-sm text-blue-600 hover:underline dark:text-blue-400"
        >
          投稿を作成する
        </Link>
      </p>

      {posts.length === 0 ? (
        <p className="mt-4 text-gray-600 dark:text-gray-400">
          投稿はまだありません。
        </p>
      ) : (
        <ul className="mt-6 space-y-2">
          {posts.map((post) => (
            <li
              key={post.id}
              className="rounded-md border border-gray-300 px-3 py-2 dark:border-gray-700"
            >
              <Link
                to={`/posts/${post.id}`}
                className="text-sm text-blue-600 hover:underline dark:text-blue-400"
              >
                {post.title}
              </Link>
            </li>
          ))}
        </ul>
      )}
    </main>
  );
}

8-3. 投稿詳細ページを作成する

続いて、投稿詳細ページを作成します。

選択した投稿の詳細情報を取得し、画面へ表示します。

【app/routes/posts/post.tsx】
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { deletePost, getPost } from "../../lib/api";

interface PostData {
  id: number;
  title: string;
  content: string;
  username: string;
}

export default function Post() {
  const { id } = useParams();
  const navigate = useNavigate();

  const [post, setPost] = useState<PostData | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [isDeleting, setIsDeleting] = useState(false);
  const [error, setError] = useState<unknown>(null);

  useEffect(() => {
    const fetchPost = async () => {
      try {
        const response = await getPost(id!);
        setPost(response.data);
      } catch (error) {
        setError(error);
      } finally {
        setIsLoading(false);
      }
    };

    fetchPost();
  }, [id]);

  const handleDelete = async () => {
    const shouldDelete = window.confirm("投稿を削除しますか?");

    if (!shouldDelete) {
      return;
    }

    try {
      setIsDeleting(true);
      setError(null);

      await deletePost(id!);
      navigate("/");
    } catch (error) {
      setError(error);
      setIsDeleting(false);
    }
  };

  if (isLoading) {
    return <p>読み込み中...</p>;
  }

  if (error) {
    return <p>投稿の取得または削除に失敗しました。</p>;
  }

  if (!post) {
    return <p>投稿が見つかりませんでした。</p>;
  }

  return (
    <main className="mx-auto max-w-2xl p-6">
      <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
        投稿詳細
      </h2>

      <p className="mt-4">
        <Link
          to="/"
          className="text-sm text-blue-600 hover:underline dark:text-blue-400"
        >
          投稿一覧へ戻る
        </Link>
      </p>

      <article className="mt-6 rounded-md border border-gray-300 p-4 dark:border-gray-700">
        <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
          {post.title}
        </h3>
        <p className="mt-2 text-gray-700 dark:text-gray-300">
          本文:{post.content}
        </p>
        <p className="mt-2 text-gray-700 dark:text-gray-300">
          ユーザー名:{post.username}
        </p>
      </article>

      <p className="mt-4">
        <Link
          to={`/posts/${id}/edit`}
          className="text-sm text-blue-600 hover:underline dark:text-blue-400"
        >
          投稿を編集する
        </Link>
      </p>

      <button
        type="button"
        onClick={handleDelete}
        disabled={isDeleting}
        className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
      >
        {isDeleting ? "削除中..." : "投稿を削除する"}
      </button>
    </main>
  );
}

8-4. 投稿編集ページを作成する

続いて、投稿編集ページを作成します。

投稿データを取得してフォームへ表示し、編集内容をRails APIへ送信できるようにします。

【app/routes/posts/edit-post.tsx】
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { getPost, updatePost } from "../../lib/api";

export default function EditPost() {
  const { id } = useParams();
  const navigate = useNavigate();

  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [username, setUsername] = useState("");
  const [isLoading, setIsLoading] = useState(true);
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<unknown>(null);

  useEffect(() => {
    const fetchPost = async () => {
      try {
        const response = await getPost(id!);
        const post = response.data;

        setTitle(post.title);
        setContent(post.content);
        setUsername(post.username);
      } catch (error) {
        setError(error);
      } finally {
        setIsLoading(false);
      }
    };

    fetchPost();
  }, [id]);

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    const updatedPost = {
      title,
      content,
      username,
    };

    try {
      setIsSubmitting(true);
      setError(null);

      await updatePost(id!, updatedPost);
      navigate(`/posts/${id}`);
    } catch (error) {
      setError(error);
      setIsSubmitting(false);
    }
  };

  if (isLoading) {
    return <p>読み込み中...</p>;
  }

  return (
    <main className="mx-auto max-w-2xl p-6">
      <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
        投稿編集
      </h2>

      <p className="mt-4">
        <Link
          to={`/posts/${id}`}
          className="text-sm text-blue-600 hover:underline dark:text-blue-400"
        >
          投稿詳細へ戻る
        </Link>
      </p>

      {!!error && (
        <p className="mt-4 text-red-600 dark:text-red-400">
          投稿の取得または更新に失敗しました。
        </p>
      )}

      <form onSubmit={handleSubmit} className="mt-6 space-y-4">
        <div>
          <label
            htmlFor="title"
            className="block text-sm font-medium text-gray-700 dark:text-gray-300"
          >
            タイトル
          </label>
          <input
            id="title"
            type="text"
            value={title}
            onChange={(event) => setTitle(event.target.value)}
            required
            className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
          />
        </div>

        <div>
          <label
            htmlFor="content"
            className="block text-sm font-medium text-gray-700 dark:text-gray-300"
          >
            本文
          </label>
          <textarea
            id="content"
            value={content}
            onChange={(event) => setContent(event.target.value)}
            required
            rows={6}
            className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
          />
        </div>

        <div>
          <label
            htmlFor="username"
            className="block text-sm font-medium text-gray-700 dark:text-gray-300"
          >
            ユーザー名
          </label>
          <input
            id="username"
            type="text"
            value={username}
            onChange={(event) => setUsername(event.target.value)}
            required
            className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
          />
        </div>

        <button
          type="submit"
          disabled={isSubmitting}
          className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
        >
          {isSubmitting ? "更新中..." : "更新"}
        </button>
      </form>
    </main>
  );
}

これでUIの構築は完了です。

9. 作成したアプリケーションを確認する

最後に、作成したアプリケーションをブラウザで確認します。

以下のコマンドを実行し、Dockerコンテナを起動してください。

$ docker compose up -d

コンテナを起動したら、ブラウザから http://localhost:5173/ にアクセスします。

投稿一覧・投稿作成・投稿詳細・投稿編集ページを順番に操作し、正しく表示されることを確認してください。

投稿一覧ページ(投稿なし)

投稿がまだ作成されていない場合は、「投稿はまだありません。」と表示されます。

投稿作成ページ

投稿作成ページを開き、タイトル・本文・ユーザー名を入力します。

入力が完了したら、「作成」ボタンを押して投稿を作成してください。

投稿一覧ページ(投稿あり)

投稿を作成すると、投稿一覧ページに作成した投稿が表示されます。

投稿詳細ページ

投稿のタイトルをクリックし、投稿詳細ページが表示されることを確認します。

投稿編集ページ

「投稿を編集する」リンクを押し、投稿編集ページが表示されることを確認します。

投稿内容を変更して「更新」ボタンを押し、編集した内容が反映されることを確認してください。

これで、React・TypeScriptとRails APIを使用した投稿サービスの実装は完了です。

終わりに

この記事では、React・TypeScriptとRails APIを用いて投稿サービスを実装しました。

現在のアプリケーションでは認証機能を実装していないため、誰でも投稿の作成・編集・削除を行うことができます。

今後は、認証機能や入力値のバリデーション、エラーハンドリングなどを追加することで、より実践的なアプリケーションへ発展させることができます。

Ruby・Rails APIを使用してアプリケーションを作成したい方はこちら

【手順解説】React・TypeScriptとRails APIで投稿サービスを作る(認証なし)

Docker・Docker Composeで環境構築をしたい方はこちら

【初心者向け】Docker ComposeでRails APIとPostgreSQLの環境構築をする方法【2026年最新版】 【初心者向け】Docker ComposeでRails APIとMySQLの環境構築をする方法【2026年最新版】

Docker・Docker Composeとは何かを知りたい方はこちら

「【入門】Dockerとは|仕組みとメリットをわかりやすく解説」のメインビジュアル【初心者向け】Dockerとは|できること・メリット・使い方をわかりやすく解説 「【初心者向け】Docker Composeとは|仕組みとメリットをわかりやすく解説」のメインビジュアル【初心者向け】Docker Composeとは|できること・メリット・使い方をわかりやすく解説

Dockerfile・docker-compose.ymlの作り方を知りたい方はこちら

「【初心者向け】Dockerfileとは?書き方をわかりやすく解説(RUN,COPY,ENV,ENTRYPOINTなど)」のメインビジュアル【初心者向け】Dockerfileとは?書き方をわかりやすく解説(RUN,COPY,ENV,ENTRYPOINTなど) 「【初心者向け】Docker Composeとは|仕組みとメリットをわかりやすく解説」のメインビジュアル【初心者向け】Docker Composeとは|できること・メリット・使い方をわかりやすく解説

Docker Desktopのインストール方法を知りたい方はこちら

Docker Desktop for Macのインストール方法【画像付きで詳しく解説】

Ruby・Railsの理解を深めたい方はこちら

「Rubyとは|Railsを理解するために最低限知っておきたい基本文法」のメインビジュアルRubyとは|Railsを理解するために最低限知っておきたい基本文法 「Railsのルーティングとは|routes.rbとresourcesの基本をRails APIでわかりやすく解説」のメインビジュアルRailsのルーティングとは|routes.rbとresourcesの基本をRails APIでわかりやすく解説 「Controllerとは|Rails APIにおけるControllerの役割と書き方をわかりやすく解説」のメインビジュアルControllerとは|Rails APIにおけるControllerの役割と書き方をわかりやすく解説 「Modelとは|Rails APIにおけるModelの役割と書き方をわかりやすく解説」のメインビジュアルModelとは|Rails APIにおけるModelの役割と書き方をわかりやすく解説