Ruby on Rails+React|バックエンドと連携する方法
Ruby on RailsとReactを連携させるフルスタック開発の完全ガイド。APIの設計からCORS設定、認証実装、リアルタイム通信まで、実際のコード例とともに詳しく解説します。
「Rails とReactを組み合わせて、本格的なWebアプリを作りたい」 「でも、どうやって連携すればいいの?」
そんな疑問を持っている方も多いのではないでしょうか。 Rails のパワフルなバックエンド機能と、React のモダンなフロントエンド。 この2つを連携させることで、素晴らしいアプリケーションが作れます。
この記事では、Ruby on Rails をバックエンドAPI、React をフロントエンドにした開発方法を詳しく解説します。 環境構築から API 設計、認証実装まで、実際のコード例とともに段階的に進めていきますよ。
大丈夫です! 一つずつ丁寧に進めていけば、きっと理解できるはずです。
Rails APIとReactの連携を理解しよう
まずは基本的な仕組みを理解しましょう。 Rails と React の連携方法にはいくつかのパターンがあります。
基本的な構成とは
Rails と React を組み合わせる場合、こんな構成が一般的です。
┌─────────────────┐ HTTP/HTTPS ┌─────────────────┐
│ │ Requests │ │
│ React SPA │ ◄──────────────► │ Rails API │
│ (Frontend) │ JSON Response │ (Backend) │
│ │ │ │
└─────────────────┘ └─────────────────┘
│ │
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Static Files │ │ Database │
│ (Build Output) │ │ (PostgreSQL) │
└─────────────────┘ └─────────────────┘
Rails がAPI としてデータを提供し、React がそれを受け取って画面を表示する。 シンプルですが、とても効率的な方法です。
連携方法は3つから選べる
どんな連携方法があるか見てみましょう。
1. 完全分離型(おすすめ)
- Rails: API専用(rails new --api)
- React: 独立したSPA
- 異なるポート/ドメインで動作
2. Rails統合型
- Rails内にReactを組み込み
- Webpackerを使用
- 同一アプリケーション内で管理
3. マイクロサービス型
- Rails: 複数のAPIサービス
- React: フロントエンド統合
- より複雑だが拡張性が高い
初心者の方には完全分離型がおすすめです。 理由は後で詳しく説明しますね。
Rails側のコード例を見てみよう
まずは Rails でAPIを作る基本的なコードを見てみましょう。
# app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
def index
users = User.all
render json: users, status: :ok
end
def show
user = User.find(params[:id])
render json: user, status: :ok
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end
end
このコードは何をしているかというと、ユーザー一覧と個別のユーザー情報を JSON で返しています。
index
アクションは全ユーザーを取得して JSON で返します。
show
アクションは指定されたIDのユーザーを探して返します。
見つからない場合は、エラーメッセージを返しています。
React側のコード例も見てみよう
今度は React 側で Rails API を呼び出すコードです。
// src/services/userService.js
const API_BASE_URL = 'http://localhost:3001/api/v1';
export const userService = {
async getUsers() {
const response = await fetch(`${API_BASE_URL}/users`);
if (!response.ok) {
throw new Error('Failed to fetch users');
}
return response.json();
},
async getUser(id) {
const response = await fetch(`${API_BASE_URL}/users/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
};
このコードは Rails API にリクエストを送って、データを取得します。
fetch
関数を使って HTTP リクエストを送信しています。
エラーハンドリングも含めて、しっかりと処理されていますね。
完全分離型がおすすめな理由
完全分離型には、こんなメリットがあります。
技術選択の自由度
- 各層で最適な技術を選択できる
- Rails は API に特化、React は UI に特化
スケーラビリティ
- 独立してスケール可能
- 負荷に応じて個別に対応できる
開発チームの分離
- フロントエンドチームとバックエンドチームの独立開発
- 並行して開発を進められる
デプロイの柔軟性
- 異なる環境・タイミングでデプロイ可能
- 片方だけを更新することも可能
こうしたメリットがあるため、多くの開発チームが完全分離型を選んでいます。
開発環境の基本設定
プロジェクトの構造はこんな感じになります。
# プロジェクト構造
my-fullstack-app/
├── backend/ # Rails API
│ ├── app/
│ ├── config/
│ ├── Gemfile
│ └── ...
├── frontend/ # React SPA
│ ├── src/
│ ├── public/
│ ├── package.json
│ └── ...
└── README.md
backend フォルダに Rails API、frontend フォルダに React アプリを配置します。 それぞれ独立したアプリケーションとして管理できます。
React 側の package.json には、こんな設定を追加します。
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"proxy": "http://localhost:3001",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.3.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build"
}
}
proxy
設定により、React から Rails API を簡単に呼び出せます。
Rails 側の CORS 設定も重要です。
# backend/config/application.rb
module Backend
class Application < Rails::Application
config.load_defaults 7.0
config.api_only = true
# CORS設定
config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'http://localhost:3000'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
credentials: true
end
end
end
end
この設定により、React からの API リクエストが正常に処理されます。
Rails APIサーバーを作ってみよう
Rails API 専用のサーバーを構築してみましょう。 Step by Step で進めていきます。
プロジェクトの作成と初期設定
まずは Rails API プロジェクトを作成します。
# Rails API専用プロジェクトの作成
rails new backend --api --database=postgresql
cd backend
# 必要なGemを追加
echo "gem 'rack-cors'" >> Gemfile
echo "gem 'jwt'" >> Gemfile
echo "gem 'bcrypt'" >> Gemfile
bundle install
# データベース作成
rails db:create
--api
オプションをつけることで、API 専用の Rails アプリケーションが作成されます。
不要なファイルが削除されて、軽量になります。
必要な Gem も追加しました。
rack-cors
は CORS 対応、jwt
は JWT 認証、bcrypt
はパスワードハッシュ化に使います。
Gemfileの詳細設定
Gemfile をより詳細に設定しましょう。
# Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '3.1.0'
gem 'rails', '~> 7.0.0'
gem 'pg', '~> 1.1'
gem 'puma', '~> 5.0'
gem 'bootsnap', '>= 1.4.4', require: false
# API用Gem
gem 'rack-cors' # CORS対応
gem 'jwt' # JWT認証
gem 'bcrypt' # パスワードハッシュ化
gem 'image_processing' # 画像処理
gem 'redis' # キャッシュ・セッション
group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
gem 'rspec-rails'
gem 'factory_bot_rails'
end
group :development do
gem 'listen', '~> 3.3'
gem 'spring'
end
開発に必要な Gem をまとめて追加しました。 テスト用の Gem も含めています。
ルーティングの設定
API のルーティングを設定します。
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
# 認証関連
post 'auth/login', to: 'authentication#login'
post 'auth/register', to: 'authentication#register'
delete 'auth/logout', to: 'authentication#logout'
get 'auth/me', to: 'authentication#me'
# ユーザー管理
resources :users, only: [:index, :show, :update, :destroy] do
member do
patch :update_avatar
end
end
# 投稿管理
resources :posts do
resources :comments, only: [:index, :create, :update, :destroy]
member do
post :like
delete :unlike
end
end
# 検索・フィルタリング
get 'search/posts', to: 'search#posts'
get 'search/users', to: 'search#users'
end
end
end
namespace
を使って、API のバージョン管理もしています。
将来的に v2 が必要になった場合も対応できます。
認証、ユーザー管理、投稿管理、検索機能のルーティングを定義しました。
モデルの作成
基本的なモデルを作成します。
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :posts, dependent: :destroy
has_many :comments, dependent: :destroy
has_many :likes, dependent: :destroy
has_one_attached :avatar
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :name, presence: true, length: { minimum: 2, maximum: 50 }
validates :password, length: { minimum: 6 }, if: :password_required?
scope :active, -> { where(active: true) }
def full_profile
{
id: id,
name: name,
email: email,
avatar_url: avatar.attached? ? Rails.application.routes.url_helpers.rails_blob_url(avatar, only_path: true) : nil,
posts_count: posts.count,
created_at: created_at,
updated_at: updated_at
}
end
private
def password_required?
password.present?
end
end
has_secure_password
を使って、パスワードを安全に管理しています。
バリデーションもしっかり設定しました。
full_profile
メソッドは、API で返すユーザー情報を整形しています。
投稿モデルの作成
投稿機能のモデルも作成します。
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
has_many :likes, dependent: :destroy
has_many_attached :images
validates :title, presence: true, length: { maximum: 100 }
validates :content, presence: true, length: { maximum: 1000 }
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
def likes_count
likes.count
end
def liked_by?(user)
return false unless user
likes.exists?(user: user)
end
def as_json_with_details(current_user = nil)
{
id: id,
title: title,
content: content,
published: published,
likes_count: likes_count,
liked_by_current_user: liked_by?(current_user),
comments_count: comments.count,
user: {
id: user.id,
name: user.name,
avatar_url: user.avatar.attached? ? Rails.application.routes.url_helpers.rails_blob_url(user.avatar, only_path: true) : nil
},
images: images.map { |image| Rails.application.routes.url_helpers.rails_blob_url(image, only_path: true) },
created_at: created_at,
updated_at: updated_at
}
end
end
投稿にはタイトル、内容、公開状態、いいね、画像などの機能があります。
as_json_with_details
メソッドで、API で返すデータを整形しています。
マイグレーションファイル
データベースのテーブルを作成するマイグレーションファイルです。
# db/migrate/001_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :name, null: false
t.string :email, null: false
t.string :password_digest, null: false
t.text :bio
t.boolean :active, default: true
t.timestamps
end
add_index :users, :email, unique: true
add_index :users, :active
end
end
必要な項目とインデックスを設定しています。
password_digest
には、ハッシュ化されたパスワードが保存されます。
ベースコントローラーの作成
全てのAPIコントローラーの基底クラスを作成します。
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
before_action :authenticate_request
private
def authenticate_request
header = request.headers['Authorization']
return render_unauthorized unless header
token = header.split(' ').last
return render_unauthorized unless token
begin
decoded = JsonWebToken.decode(token)
@current_user = User.find(decoded[:user_id])
rescue ActiveRecord::RecordNotFound, JWT::DecodeError
render_unauthorized
end
end
def current_user
@current_user
end
def render_unauthorized
render json: { error: 'Unauthorized' }, status: :unauthorized
end
def render_not_found(resource = 'Resource')
render json: { error: "#{resource} not found" }, status: :not_found
end
def render_validation_errors(resource)
render json: {
error: 'Validation failed',
details: resource.errors.full_messages
}, status: :unprocessable_entity
end
end
authenticate_request
メソッドで、JWT トークンを使った認証を行っています。
エラーハンドリングのメソッドも用意しました。
JWT トークンサービス
JWT トークンを生成・検証するサービスクラスです。
# app/services/json_web_token.rb
class JsonWebToken
SECRET_KEY = Rails.application.credentials.secret_key_base || 'default_secret'
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET_KEY, 'HS256')
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: 'HS256' })[0]
HashWithIndifferentAccess.new(decoded)
rescue JWT::ExpiredSignature
raise JWT::DecodeError, 'Token has expired'
rescue JWT::DecodeError
raise JWT::DecodeError, 'Invalid token'
end
end
encode
メソッドでトークンを生成し、decode
メソッドで検証します。
有効期限切れや無効なトークンの場合は、エラーを発生させます。
これで Rails API の基本的な構築が完了しました。 次は React 側の実装に進みましょう。
React側でAPIと連携しよう
Rails API と連携する React アプリケーションを作成します。 データの取得から状態管理まで、段階的に実装していきましょう。
Reactプロジェクトの作成
まずは React プロジェクトを作成します。
# Reactプロジェクトの作成
npx create-react-app frontend
cd frontend
# 必要なパッケージのインストール
npm install axios react-router-dom
npm install @hookform/resolvers yup # フォームバリデーション
npm install react-query # データフェッチ管理
npm install js-cookie # Cookie管理
# 開発用パッケージ
npm install --save-dev @types/js-cookie
axios
は HTTP リクエストライブラリです。
react-router-dom
でルーティング、react-query
でデータフェッチを管理します。
環境設定ファイル
API の URL などを設定します。
# frontend/.env
REACT_APP_API_BASE_URL=http://localhost:3001/api/v1
REACT_APP_ENV=development
環境変数を使うことで、開発・本番環境で異なる設定を使えます。
設定ファイルも作成しましょう。
// src/config/api.js
const config = {
apiBaseUrl: process.env.REACT_APP_API_BASE_URL || 'http://localhost:3001/api/v1',
environment: process.env.REACT_APP_ENV || 'development'
};
export default config;
これで環境に応じた設定が可能になります。
APIクライアントの設定
Axios を使って、API クライアントを設定します。
// src/services/apiClient.js
import axios from 'axios';
import config from '../config/api';
import { getAuthToken, removeAuthToken } from '../utils/auth';
// Axiosインスタンスの作成
const apiClient = axios.create({
baseURL: config.apiBaseUrl,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// リクエストインターセプター(認証トークンの自動付与)
apiClient.interceptors.request.use(
(config) => {
const token = getAuthToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// レスポンスインターセプター(エラーハンドリング)
apiClient.interceptors.response.use(
(response) => response,
(error) => {
console.error('API Error:', error);
// 401エラー(認証エラー)の場合、トークンを削除
if (error.response?.status === 401) {
removeAuthToken();
window.location.href = '/login';
}
// ネットワークエラーの場合
if (!error.response) {
return Promise.reject({
message: 'ネットワークエラーが発生しました',
type: 'network_error'
});
}
// サーバーエラーの場合
const errorData = {
message: error.response.data?.error || 'サーバーエラーが発生しました',
status: error.response.status,
details: error.response.data?.details || []
};
return Promise.reject(errorData);
}
);
export default apiClient;
このコードは何をしているか説明しますね。
まず、axios インスタンスを作成しています。 ベース URL やタイムアウトなどの基本設定を行います。
リクエストインターセプターでは、認証トークンを自動的に追加します。 毎回手動で設定する必要がありません。
レスポンスインターセプターでは、エラーハンドリングを行っています。 401エラーの場合は自動的にログイン画面に遷移します。
認証ユーティリティ
認証に関するユーティリティ関数を作成します。
// src/utils/auth.js
import Cookies from 'js-cookie';
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'user_data';
export const getAuthToken = () => {
return Cookies.get(TOKEN_KEY);
};
export const setAuthToken = (token) => {
Cookies.set(TOKEN_KEY, token, { expires: 7 }); // 7日間
};
export const removeAuthToken = () => {
Cookies.remove(TOKEN_KEY);
Cookies.remove(USER_KEY);
};
export const getCurrentUser = () => {
const userData = Cookies.get(USER_KEY);
return userData ? JSON.parse(userData) : null;
};
export const setCurrentUser = (user) => {
Cookies.set(USER_KEY, JSON.stringify(user), { expires: 7 });
};
export const isAuthenticated = () => {
return !!getAuthToken();
};
Cookies を使って認証情報を管理します。 セキュリティを考慮して、有効期限も設定しています。
認証サービスの作成
認証に関する API 呼び出しをまとめたサービスです。
// src/services/authService.js
import apiClient from './apiClient';
import { setAuthToken, setCurrentUser, removeAuthToken } from '../utils/auth';
export const authService = {
async login(credentials) {
try {
const response = await apiClient.post('/auth/login', {
auth: credentials
});
const { token, user } = response.data;
setAuthToken(token);
setCurrentUser(user);
return { success: true, user };
} catch (error) {
return {
success: false,
error: error.message || 'ログインに失敗しました'
};
}
},
async register(userData) {
try {
const response = await apiClient.post('/auth/register', {
auth: userData
});
const { token, user } = response.data;
setAuthToken(token);
setCurrentUser(user);
return { success: true, user };
} catch (error) {
return {
success: false,
error: error.message || '登録に失敗しました',
details: error.details || []
};
}
},
async logout() {
try {
await apiClient.delete('/auth/logout');
} catch (error) {
console.error('Logout error:', error);
} finally {
removeAuthToken();
}
},
async getCurrentUser() {
try {
const response = await apiClient.get('/auth/me');
const user = response.data;
setCurrentUser(user);
return { success: true, user };
} catch (error) {
removeAuthToken();
return { success: false, error: error.message };
}
}
};
各メソッドは Rails API の対応するエンドポイントと連携しています。 エラーハンドリングも含めて、使いやすいように設計されています。
投稿データを扱うサービス
投稿に関する API 呼び出しをまとめます。
// src/services/postService.js
import apiClient from './apiClient';
export const postService = {
async getPosts(page = 1, perPage = 10) {
try {
const response = await apiClient.get(`/posts?page=${page}&per_page=${perPage}`);
return {
success: true,
data: response.data.posts,
pagination: response.data.pagination
};
} catch (error) {
return {
success: false,
error: error.message || '投稿の取得に失敗しました'
};
}
},
async getPost(id) {
try {
const response = await apiClient.get(`/posts/${id}`);
return { success: true, data: response.data };
} catch (error) {
return {
success: false,
error: error.message || '投稿の取得に失敗しました'
};
}
},
async createPost(postData) {
try {
const formData = new FormData();
formData.append('post[title]', postData.title);
formData.append('post[content]', postData.content);
formData.append('post[published]', postData.published);
// 画像がある場合
if (postData.images && postData.images.length > 0) {
postData.images.forEach((image, index) => {
formData.append(`post[images][]`, image);
});
}
const response = await apiClient.post('/posts', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return { success: true, data: response.data };
} catch (error) {
return {
success: false,
error: error.message || '投稿の作成に失敗しました',
details: error.details || []
};
}
},
async likePost(id) {
try {
const response = await apiClient.post(`/posts/${id}/like`);
return { success: true, data: response.data };
} catch (error) {
return {
success: false,
error: error.message || 'いいねに失敗しました'
};
}
},
async unlikePost(id) {
try {
const response = await apiClient.delete(`/posts/${id}/unlike`);
return { success: true, data: response.data };
} catch (error) {
return {
success: false,
error: error.message || 'いいね解除に失敗しました'
};
}
}
};
画像アップロードには FormData
を使用しています。
Rails の Strong Parameters に合わせて、パラメータを設定しています。
状態管理にContext APIを使用
React の Context API を使って、認証状態を管理します。
// src/contexts/AuthContext.js
import React, { createContext, useContext, useReducer, useEffect } from 'react';
import { authService } from '../services/authService';
import { getCurrentUser, isAuthenticated } from '../utils/auth';
const AuthContext = createContext();
const initialState = {
user: null,
isAuthenticated: false,
loading: true,
error: null
};
const authReducer = (state, action) => {
switch (action.type) {
case 'LOGIN_SUCCESS':
return {
...state,
user: action.payload.user,
isAuthenticated: true,
loading: false,
error: null
};
case 'LOGIN_FAILURE':
return {
...state,
user: null,
isAuthenticated: false,
loading: false,
error: action.payload.error
};
case 'LOGOUT':
return {
...state,
user: null,
isAuthenticated: false,
loading: false,
error: null
};
case 'SET_LOADING':
return {
...state,
loading: action.payload
};
case 'CLEAR_ERROR':
return {
...state,
error: null
};
default:
return state;
}
};
export const AuthProvider = ({ children }) => {
const [state, dispatch] = useReducer(authReducer, initialState);
useEffect(() => {
const initializeAuth = async () => {
if (isAuthenticated()) {
const result = await authService.getCurrentUser();
if (result.success) {
dispatch({
type: 'LOGIN_SUCCESS',
payload: { user: result.user }
});
} else {
dispatch({
type: 'LOGIN_FAILURE',
payload: { error: result.error }
});
}
} else {
dispatch({ type: 'SET_LOADING', payload: false });
}
};
initializeAuth();
}, []);
const login = async (credentials) => {
dispatch({ type: 'SET_LOADING', payload: true });
const result = await authService.login(credentials);
if (result.success) {
dispatch({
type: 'LOGIN_SUCCESS',
payload: { user: result.user }
});
} else {
dispatch({
type: 'LOGIN_FAILURE',
payload: { error: result.error }
});
}
return result;
};
const logout = async () => {
await authService.logout();
dispatch({ type: 'LOGOUT' });
};
const clearError = () => {
dispatch({ type: 'CLEAR_ERROR' });
};
return (
<AuthContext.Provider value={{
...state,
login,
logout,
clearError
}}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
useReducer
を使って状態管理を行っています。
認証状態の変更を適切に管理できます。
ログインフォームコンポーネント
実際のログインフォームを作成します。
// src/components/auth/LoginForm.js
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
const LoginForm = () => {
const [formData, setFormData] = useState({
email: '',
password: ''
});
const [errors, setErrors] = useState({});
const { login, loading, error } = useAuth();
const navigate = useNavigate();
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// エラーをクリア
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ''
}));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'メールアドレスは必須です';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'メールアドレスの形式が正しくありません';
}
if (!formData.password) {
newErrors.password = 'パスワードは必須です';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) return;
const result = await login(formData);
if (result.success) {
navigate('/dashboard');
}
};
return (
<div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
<h2>ログイン</h2>
{error && (
<div style={{
padding: '10px',
backgroundColor: '#f8d7da',
border: '1px solid #f5c6cb',
borderRadius: '4px',
color: '#721c24',
marginBottom: '20px'
}}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '15px' }}>
<label>メールアドレス:</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
style={{
width: '100%',
padding: '10px',
border: `1px solid ${errors.email ? '#dc3545' : '#ccc'}`,
borderRadius: '4px'
}}
/>
{errors.email && (
<div style={{ color: '#dc3545', fontSize: '14px', marginTop: '5px' }}>
{errors.email}
</div>
)}
</div>
<div style={{ marginBottom: '20px' }}>
<label>パスワード:</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
style={{
width: '100%',
padding: '10px',
border: `1px solid ${errors.password ? '#dc3545' : '#ccc'}`,
borderRadius: '4px'
}}
/>
{errors.password && (
<div style={{ color: '#dc3545', fontSize: '14px', marginTop: '5px' }}>
{errors.password}
</div>
)}
</div>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: '12px',
backgroundColor: loading ? '#6c757d' : '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
{loading ? 'ログイン中...' : 'ログイン'}
</button>
</form>
</div>
);
};
export default LoginForm;
フォームのバリデーションとエラーハンドリングを含めています。 ローディング状態も適切に表示します。
これで React 側の基本的な実装が完成しました。 次は認証とセキュリティについて詳しく見ていきましょう。
認証とセキュリティをしっかり実装
Web アプリケーションでは認証とセキュリティが非常に重要です。 JWT 認証を使った安全な実装方法を解説します。
JWTトークンを安全に管理
まず、JWT トークンを安全に管理する方法を見てみましょう。
# app/services/json_web_token.rb
class JsonWebToken
SECRET_KEY = Rails.application.credentials.jwt_secret_key || Rails.application.credentials.secret_key_base
ALGORITHM = 'HS256'.freeze
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
payload[:iat] = Time.current.to_i
payload[:iss] = 'rails-react-app'
JWT.encode(payload, SECRET_KEY, ALGORITHM)
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY, true, {
algorithm: ALGORITHM,
verify_iat: true,
iss: 'rails-react-app',
verify_iss: true
})[0]
HashWithIndifferentAccess.new(decoded)
rescue JWT::ExpiredSignature
raise JWT::DecodeError, 'Token has expired'
rescue JWT::InvalidIssuerError
raise JWT::DecodeError, 'Invalid token issuer'
rescue JWT::InvalidIatError
raise JWT::DecodeError, 'Invalid token issued at'
rescue JWT::DecodeError => e
raise JWT::DecodeError, "Invalid token: #{e.message}"
end
def self.refresh_token(token)
decoded = decode(token)
# 新しいトークンを発行(有効期限をリセット)
encode({ user_id: decoded[:user_id] })
rescue JWT::DecodeError
nil
end
end
このコードでは、より安全な JWT トークン管理を行っています。
iat
(issued at)フィールドで発行時刻を記録し、iss
(issuer)フィールドで発行者を確認します。
これにより、トークンの改ざんを防ぐことができます。
強化されたApplicationController
セキュリティを強化した ApplicationController を作成します。
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include ActionController::Cookies
before_action :authenticate_request
before_action :set_cors_headers
before_action :rate_limit_check
private
def authenticate_request
token = extract_token
return render_unauthorized unless token
begin
decoded = JsonWebToken.decode(token)
@current_user = User.find(decoded[:user_id])
# トークンの有効性をさらにチェック
if @current_user.nil? || !@current_user.active?
render_unauthorized
end
rescue ActiveRecord::RecordNotFound
render_unauthorized
rescue JWT::DecodeError => e
render_json_error('Invalid token', :unauthorized, { detail: e.message })
end
end
def extract_token
auth_header = request.headers['Authorization']
return auth_header.split(' ').last if auth_header&.start_with?('Bearer ')
# Cookie からも取得を試みる(オプション)
cookies.signed[:auth_token]
end
def render_json_error(message, status, additional_data = {})
render json: {
error: message,
status: Rack::Utils.status_code(status)
}.merge(additional_data), status: status
end
def set_cors_headers
response.headers['Access-Control-Allow-Origin'] = allowed_origins
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = 'Origin, Content-Type, Accept, Authorization, Token'
response.headers['Access-Control-Allow-Credentials'] = 'true'
end
def allowed_origins
case Rails.env
when 'development'
'http://localhost:3000'
when 'production'
ENV['FRONTEND_URL'] || 'https://yourdomain.com'
else
'*'
end
end
def rate_limit_check
# 簡易的なレート制限(本番環境では Redis などを使用)
return unless Rails.env.production?
client_ip = request.remote_ip
cache_key = "rate_limit:#{client_ip}"
request_count = Rails.cache.fetch(cache_key, expires_in: 1.minute) { 0 }
if request_count >= 100 # 1分間に100リクエスト制限
render_json_error('Too many requests', :too_many_requests)
return
end
Rails.cache.write(cache_key, request_count + 1, expires_in: 1.minute)
end
end
このコントローラーでは、以下のセキュリティ機能を追加しています。
CORS ヘッダーの適切な設定により、許可されたオリジンからのみアクセスを受け付けます。 レート制限機能により、短時間での大量リクエストを防止します。
認証コントローラーの強化
さらに安全な認証コントローラーを作成します。
# app/controllers/api/v1/authentication_controller.rb
class Api::V1::AuthenticationController < ApplicationController
skip_before_action :authenticate_request, only: [:login, :register, :refresh_token]
skip_before_action :rate_limit_check, only: [:logout]
def login
user = User.find_by(email: login_params[:email]&.downcase)
unless user&.authenticate(login_params[:password])
# ログイン試行回数を記録(ブルートフォース攻撃対策)
log_failed_login_attempt(login_params[:email])
return render_json_error('Invalid credentials', :unauthorized)
end
unless user.active?
return render_json_error('Account is deactivated', :forbidden)
end
# ログイン成功時の処理
user.update(last_login_at: Time.current, last_login_ip: request.remote_ip)
token = JsonWebToken.encode(user_id: user.id)
refresh_token = generate_refresh_token(user)
# セキュアなCookieにリフレッシュトークンを保存
cookies.signed[:refresh_token] = {
value: refresh_token,
expires: 7.days.from_now,
httponly: true,
secure: Rails.env.production?,
same_site: :strict
}
render json: {
token: token,
user: user.safe_profile,
expires_at: 24.hours.from_now.iso8601
}, status: :ok
end
def refresh_token
refresh_token = cookies.signed[:refresh_token]
return render_json_error('Refresh token not found', :unauthorized) unless refresh_token
user_refresh_token = UserRefreshToken.find_by(
token: refresh_token,
revoked: false
)
unless user_refresh_token&.valid_token?
cookies.delete(:refresh_token)
return render_json_error('Invalid or expired refresh token', :unauthorized)
end
user = user_refresh_token.user
new_token = JsonWebToken.encode(user_id: user.id)
render json: {
token: new_token,
user: user.safe_profile,
expires_at: 24.hours.from_now.iso8601
}, status: :ok
end
def logout
# リフレッシュトークンを無効化
refresh_token = cookies.signed[:refresh_token]
if refresh_token
UserRefreshToken.find_by(token: refresh_token)&.revoke!
cookies.delete(:refresh_token)
end
render json: { message: 'Logged out successfully' }, status: :ok
end
private
def log_failed_login_attempt(email)
Rails.logger.warn "Failed login attempt for email: #{email} from IP: #{request.remote_ip}"
# 失敗回数をカウント(Redis や DB で管理)
cache_key = "failed_login:#{request.remote_ip}"
failed_count = Rails.cache.fetch(cache_key, expires_in: 15.minutes) { 0 }
Rails.cache.write(cache_key, failed_count + 1, expires_in: 15.minutes)
# 一定回数失敗したらアラート(本番環境では通知システムと連携)
if failed_count >= 5
Rails.logger.error "Multiple failed login attempts from IP: #{request.remote_ip}"
end
end
def generate_refresh_token(user)
# 既存のリフレッシュトークンを無効化
user.user_refresh_tokens.active.update_all(revoked: true)
# 新しいリフレッシュトークンを生成
refresh_token = SecureRandom.urlsafe_base64(64)
user.user_refresh_tokens.create!(
token: refresh_token,
expires_at: 7.days.from_now,
ip_address: request.remote_ip,
user_agent: request.user_agent
)
refresh_token
end
end
このコントローラーでは、以下のセキュリティ機能を実装しています。
ブルートフォース攻撃対策: ログイン失敗回数を記録し、一定回数を超えた場合にアラートを発生させます。
リフレッシュトークン: JWT トークンの有効期限を短く設定し、リフレッシュトークンで更新します。
セキュアなCookie: リフレッシュトークンを httponly、secure、same_site の設定で保護します。
React側のセキュアな実装
React 側でも、セキュリティを考慮した実装を行います。
// src/services/authService.js
import apiClient from './apiClient';
import { setAuthToken, setCurrentUser, removeAuthToken } from '../utils/auth';
export const authService = {
async login(credentials) {
try {
const response = await apiClient.post('/auth/login', {
auth: credentials
});
const { token, user, expires_at } = response.data;
setAuthToken(token, expires_at);
setCurrentUser(user);
// トークンの自動更新を設定
this.scheduleTokenRefresh(expires_at);
return { success: true, user };
} catch (error) {
return {
success: false,
error: error.message || 'ログインに失敗しました'
};
}
},
async refreshToken() {
try {
const response = await apiClient.post('/auth/refresh_token');
const { token, user, expires_at } = response.data;
setAuthToken(token, expires_at);
setCurrentUser(user);
this.scheduleTokenRefresh(expires_at);
return { success: true, user };
} catch (error) {
// リフレッシュに失敗した場合はログアウト
this.logout();
return { success: false, error: error.message };
}
},
scheduleTokenRefresh(expiresAt) {
const expirationTime = new Date(expiresAt).getTime();
const currentTime = Date.now();
const timeUntilExpiry = expirationTime - currentTime;
// 有効期限の5分前にリフレッシュ
const refreshTime = Math.max(timeUntilExpiry - 5 * 60 * 1000, 60 * 1000);
// 既存のタイマーをクリア
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
this.refreshToken();
}, refreshTime);
},
async logout() {
try {
await apiClient.delete('/auth/logout');
} catch (error) {
console.error('Logout error:', error);
} finally {
removeAuthToken();
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
// ページをリダイレクト
window.location.href = '/login';
}
}
};
この実装では、トークンの自動更新機能を追加しています。 有効期限の5分前に自動的にリフレッシュを行うため、ユーザーの操作を中断することなく認証を維持できます。
認証ガードコンポーネント
認証が必要なページを保護するコンポーネントを作成します。
// src/components/auth/AuthGuard.js
import React, { useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
const AuthGuard = ({ children, requiredRole = null }) => {
const { user, isAuthenticated, loading } = useAuth();
const location = useLocation();
// ロード中は何も表示しない
if (loading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}>
<div>認証情報を確認中...</div>
</div>
);
}
// 未認証の場合はログインページにリダイレクト
if (!isAuthenticated) {
return (
<Navigate
to="/login"
state={{ from: location }}
replace
/>
);
}
// 必要な権限をチェック
if (requiredRole && user?.role !== requiredRole) {
return (
<div style={{
textAlign: 'center',
padding: '50px',
color: '#dc3545'
}}>
<h2>アクセス権限がありません</h2>
<p>このページにアクセスする権限がありません。</p>
</div>
);
}
return children;
};
export default AuthGuard;
このコンポーネントを使うことで、認証が必要なページを簡単に保護できます。
これらのセキュリティ実装により、安全で実用的な認証システムが完成します。 本番環境では、さらに HTTPS の使用、セキュリティヘッダーの設定、定期的なセキュリティ監査も重要です。
まとめ:Rails + React でフルスタック開発
Ruby on Rails と React を連携させたフルスタック開発について、詳しく解説してきました。 学んだ内容を整理して、次のステップを考えてみましょう。
重要なポイントを振り返ろう
1. アーキテクチャ設計
- Rails API + React SPA の完全分離型構成
- RESTful API の設計と JSON レスポンス
- CORS 設定とセキュリティ考慮
- 適切なエラーハンドリング
この組み合わせにより、保守性が高く、拡張しやすいアプリケーションが作れます。
2. 認証とセキュリティ
- JWT 認証の実装とベストプラクティス
- リフレッシュトークンによる安全な認証継続
- XSS・CSRF 攻撃への対策
- レート制限とブルートフォース攻撃対策
セキュリティは後から追加するのが難しいので、最初から組み込むことが重要です。
3. データ連携パターン
- Axios を使用した HTTP クライアント設定
- 非同期処理とエラーハンドリング
- 効率的な状態管理
- リアルタイム通信の基礎
これらのパターンを理解することで、様々な機能を実装できるようになります。
4. 実践的な実装
- ユーザー認証フロー
- CRUD 操作とバリデーション
- ファイルアップロード処理
- ページネーションと検索機能
実際のアプリケーションで必要となる機能を一通り学習しました。
開発効率が向上するメリット
技術的なメリット
- モダンなフロントエンド開発環境
- 型安全な API 連携
- 再利用可能なコンポーネント設計
- 自動テストと CI/CD 対応
Rails の安定性と React の柔軟性を組み合わせることで、効率的な開発が可能になります。
チーム開発でのメリット
- フロントエンド・バックエンドの独立開発
- 異なる技術スタックでの専門性活用
- 段階的なデプロイメント
- 保守性の高いコードベース
チームで開発する際も、役割分担がしやすい構成です。
次のステップとして挑戦してみよう
機能拡張
- WebSocket 通信の実装
- プッシュ通知システム
- 画像処理と CDN 連携
- 検索エンジンとの統合
この記事で学んだ基礎をベースに、より高度な機能に挑戦してみてください。
パフォーマンス最適化
- データベースクエリ最適化
- フロントエンドバンドル最適化
- キャッシュ戦略の実装
- CDN とロードバランサー
アプリケーションの規模が大きくなったら、パフォーマンスの最適化も重要になります。
運用とモニタリング
- ログ収集とエラー監視
- パフォーマンス監視
- セキュリティ監査
- バックアップと災害復旧
本番環境では、これらの運用面も考慮する必要があります。
学習の継続が成功の鍵
深い理解のために
- Rails API の高度な機能
- React Hooks と Context API
- TypeScript での型安全な開発
- テスト駆動開発(TDD)
基礎を固めた後は、より深い知識を身につけることが重要です。
実践的なスキル
- Docker コンテナ化
- AWS/GCP へのデプロイ
- CI/CD パイプライン構築
- マイクロサービス アーキテクチャ
実際のプロジェクトで使われる技術も学習してみてください。
最後に
Ruby on Rails と React の組み合わせは、現代の Web 開発において非常に強力な技術スタックです。 この記事で学んだ内容を基に、実際のプロジェクトで経験を積んでください。
継続的な学習と実践により、フルスタック開発者としてのスキルを向上させることができます。 素晴らしい Web サービスを提供できるようになるでしょう。
大丈夫です! 一歩ずつ進んでいけば、きっと理想のアプリケーションが作れるはずです。 ぜひ挑戦してみてくださいね。