Ruby on Rails+React|バックエンドと連携する方法

Ruby on RailsとReactを連携させるフルスタック開発の完全ガイド。APIの設計からCORS設定、認証実装、リアルタイム通信まで、実際のコード例とともに詳しく解説します。

Learning Next 運営
73 分で読めます

「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 サービスを提供できるようになるでしょう。

大丈夫です! 一歩ずつ進んでいけば、きっと理想のアプリケーションが作れるはずです。 ぜひ挑戦してみてくださいね。

関連記事