今回の記事では、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つずつ定義したい場合はgetやpostなどを使用します。
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通信が行えるようになります。
まずは、Gemfileのrack-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
以下のように設定を変更します。
- コードのコメントアウトを解除
- 「origins “example.com”」を「origins ENV[‘FRONTEND_ORIGIN’]」に変更
- 「.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コンテナを再ビルドします。
docker compose downを実行し、コンテナを終了するdocker compose buildを実行し、ビルドする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.tsxやpost.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を用いて投稿サービスを実装しました。
現在のアプリケーションでは認証機能を実装していないため、誰でも投稿の作成・編集・削除を行うことができます。
今後は、認証機能や入力値のバリデーション、エラーハンドリングなどを追加することで、より実践的なアプリケーションへ発展させることができます。

