ホーム > カテゴリ > Ruby・Ruby on Rails >

Rails + React + AjaxでCRUDのサンプルプロジェクト [Hello World]

React初心者が公式サイトで基礎を学んだ後に作るReact + AjaxによるCRUD(作成/読み込み/更新/削除)のサンプルプロジェクトです。

Twitterのように「つぶやき」を投稿可能で編集/削除もできます。

DEMO

https://www.petitmonte.com/rails-demo/react_crud

ソース一式

https://github.com/TakeshiOkamoto/mpp_react_crud

※学習用の為、ライセンスはパブリックドメイン

目次

1. プロジェクトの作成
2. 各パッケージをインストール
3. Bootstrapの導入
4. config/application.rb
5. データベース設定
6. モデルの作成
7. db/seeds.rb
8. マイグレーション
9. コントローラー/ビューの作成
10. config/routes.rb
11. 各コード(ビュー/コントローラー/モデル/JSX)
12. 最後に

1. プロジェクトの作成

これ以下はソース一式からではなく、一からプロジェクトを作成する方の為の解説です。※各コードの解説もあります。

新規プロジェクトに「React」を追加してプロジェクトを作成する。

cd ~/
mkdir myapp
cd myapp
rails new . --webpack=react --skip-turbolinks --skip-action-mailer --skip-action-mailbox --skip-active-storage --skip-test -d mysql

実行後、app/javascript/packs/hello_react.jsxのファイルが作成されるので「react_crud.jsx」の名称に変更する。

2. 各パッケージをインストール

次のyarnコマンドを実行します。

// ReactをIE9/10/11に対応させる
yarn add react-app-polyfill
// 日付、時刻の操作(日本語対応)
yarn add date-fns
// FormDataをIEに対応させる
yarn add formdata-polyfill

3. Bootstrapの導入

// Gemfileに以下を追加してbundleする
gem 'bootstrap'
 
// 手動で削除
app/assets/stylesheets/application.css
// 手動で作成
app/assets/stylesheets/application.scss
 
application.scssに次のコードをコピペする
/*
 * This is a manifest file that'll be compiled into application.css, which will include all the files
 * listed below.
 *
 * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
 * vendor/assets/stylesheets directory can be referenced here using a relative path.
 *
 * You're free to add application-wide styles to this file and they'll appear at the bottom of the
 * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
 * files in this directory. Styles in this file should be added after the last require_* statement.
 * It is generally better to create a new file per style scope.
 *
 *= require_tree .
 *= require_self
 */
 
@import "bootstrap"

4. config/application.rb

タイムゾーンの設定。

class Application < Rails::Application

  ・・・省略 ・・・
  
  config.time_zone = 'Asia/Tokyo'

  ・・・省略 ・・・

end

5. データベース設定

データベースの設定ユーザー/データベースの生成を参照。

6. モデルの作成

// メイン用
bin/rails g model react_crud_data name:string comment:text
// バックアップ用
bin/rails g model react_crud_data_bk name:string comment:text

7. db/seeds.rb

テーブルの初期データの設定です。

# This file should contain all the record creation needed to seed the database with its default values.
# The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup).
#
# Examples:
#
#   movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }])
#   Character.create(name: 'Luke', movie: movies.first)

ReactCrudDatum.create(id:1, name: 'プチモンテ', comment: 'Ubuntu16.04にLazarusをインストールする [RADによる開発環境]',
                              created_at: '2020-02-26 13:37:12', updated_at: '2020-02-26 13:37:12')  
ReactCrudDatum.create(id:2, name: 'プチモンテ', comment: '青色申告決算書 & 仕訳帳システム',
                              created_at: '2020-02-27 20:00:12', updated_at: '2020-02-27 20:00:12')  
ReactCrudDatum.create(id:3, name: 'プチモンテ', comment: 'Rails6プロジェクトの各種初期設定',
                              created_at: '2020-02-28 05:30:12', updated_at: '2020-02-28 05:30:12')
ReactCrudDatum.create(id:4, name: 'プチモンテ', comment: 'NginxにRoundcubeのWebメールシステムを導入する [CentOS]',
                              created_at: '2020-03-01 12:45:56', updated_at: '2020-03-01 12:45:56')                              
ReactCrudDatum.create(id:5, name: 'プチモンテ', comment: 'ネットワークカメラを用いた顔認識及び人物特定システムの構築 [防犯カメラの自作]',
                              created_at: '2020-03-02 18:22:08', updated_at: '2020-03-02 18:22:08')   
ReactCrudDatum.create(id:6, name: 'プチモンテ', comment: 'Android App Bundle(*.aab)でゲームをアップロード [Unity]',
                              created_at: '2020-03-03 22:28:00', updated_at: '2020-03-03 22:28:00')   
ReactCrudDatum.create(id:7, name: 'プチモンテ', comment: '収縮と膨張によるノイズ除去のサンプルコード(2値画像用)',
                              created_at: '2020-03-03 23:30:11', updated_at: '2020-03-03 23:30:11')   
ReactCrudDatum.create(id:8, name: 'プチモンテ', comment: 'JavaScriptでC/C++コードを実行してネイティブアプリのように高速にする [WebAssembly]',
                              created_at: '2020-03-03 23:47:52', updated_at: '2020-03-03 23:47:52')  
                              
ReactCrudDataBk.create(id:1, name: 'プチモンテ', comment: 'Ubuntu16.04にLazarusをインストールする [RADによる開発環境]',
                              created_at: '2020-02-26 13:37:12', updated_at: '2020-02-26 13:37:12')  
ReactCrudDataBk.create(id:2, name: 'プチモンテ', comment: '青色申告決算書 & 仕訳帳システム',
                              created_at: '2020-02-27 20:00:12', updated_at: '2020-02-27 20:00:12')  
ReactCrudDataBk.create(id:3, name: 'プチモンテ', comment: 'Rails6プロジェクトの各種初期設定',
                              created_at: '2020-02-28 05:30:12', updated_at: '2020-02-28 05:30:12')
ReactCrudDataBk.create(id:4, name: 'プチモンテ', comment: 'NginxにRoundcubeのWebメールシステムを導入する [CentOS]',
                              created_at: '2020-03-01 12:45:56', updated_at: '2020-03-01 12:45:56')                              
ReactCrudDataBk.create(id:5, name: 'プチモンテ', comment: 'ネットワークカメラを用いた顔認識及び人物特定システムの構築 [防犯カメラの自作]',
                              created_at: '2020-03-02 18:22:08', updated_at: '2020-03-02 18:22:08')   
ReactCrudDataBk.create(id:6, name: 'プチモンテ', comment: 'Android App Bundle(*.aab)でゲームをアップロード [Unity]',
                              created_at: '2020-03-03 22:28:00', updated_at: '2020-03-03 22:28:00')   
ReactCrudDataBk.create(id:7, name: 'プチモンテ', comment: '収縮と膨張によるノイズ除去のサンプルコード(2値画像用)',
                              created_at: '2020-03-03 23:30:11', updated_at: '2020-03-03 23:30:11')   
ReactCrudDataBk.create(id:8, name: 'プチモンテ', comment: 'JavaScriptでC/C++コードを実行してネイティブアプリのように高速にする [WebAssembly]',
                              created_at: '2020-03-03 23:47:52', updated_at: '2020-03-03 23:47:52')   

8. マイグレーション

// 各テーブルの作成
bin/rails db:migrate
// 各テーブルの初期データの作成
bin/rails db:seed

9. コントローラー/ビューの作成

SPA(Single Page Application)なので1つだけです。

bin/rails g controller react_crud_data index

10. config/routes.rb

この設定はAjaxから呼び出すAPIのURLともなります。

Rails.application.routes.draw do
  
  # ルート
  root to: 'react_crud_data#index'

  get    'react_crud_data/index'  
  get    'react_crud_data/new',  to: 'react_crud_data#new',     as: 'new_react_crud_data'
  post   'react_crud_data',      to: 'react_crud_data#create'
  put    'react_crud_data/:id',  to: 'react_crud_data#update'
  delete 'react_crud_data/:id',  to: 'react_crud_data#destroy'
end

11. 各コード(ビュー/コントローラー/モデル/JSX)

11.1 ビュー

[app/views/layouts/application.html.erb]

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title><%= "Rails + React + AjaxでCRUDのサンプルプロジェクト" %></title>
    <meta name="robots" content="noindex, nofollow" />
    <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1" />    
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag 'application', media: 'all' %>
    <%= javascript_pack_tag 'application' %>
            
    <%# react_crud.jsxの読み込み %>
    <%= javascript_pack_tag 'react_crud' %>   
  </head>
  <body>
    <div class="navbar navbar-expand-md navbar-light bg-primary">
      <div class="navbar-brand text-white"><%= "Rails + React + AjaxでCRUDのサンプルプロジェクト" %></div>
      <ul class="navbar-nav ml-auto">
        <li class="nav-item"><%= link_to '全て初期化',  new_react_crud_data_path, class: 'nav-link',style: "color:#fff;" %></li> 
      </ul>       
    </div>   
    <div class="container">
       <%= yield %>       
    </div>    
    <br>       
    <nav class="container bg-primary p-2 text-center">
      <div class="text-center text-white">
         React "CRUD" Sample
      </div>  
      <div class="text-center text-white">       
        Takeshi Okamoto wrote the code.
      </div>  
      <p></p>      
    </nav>    
  </body>
</html>

[app/views/react_crud_data/index.html.erb]

<div id="root"></div>

11.2 コントローラー

[app/controllers/react_crud_data_controller.rb]

class ReactCrudDataController < ApplicationController
  before_action :set_datum, only: [:update, :destroy]
  
  # 英語のdataは複数形、datumは単数形
  
  def index
    @data = ReactCrudDatum.all.order(updated_at: "DESC") 
    
    respond_to do |format|

        # HTML用
        format.html
        
        # JSON用
        format.json { render json: @data}
        
        # JSONは次のような形式となる
        #
        #  [
        #    {"id":1,"name":"プチモンテ"}
        #    {"id":2,"name":"プチラボ"}
        #    {"id":3,"name":"@ゲーム"}        
        #  ]        
    end
  end
  
  # 全て初期化
  def new
    
    ActiveRecord::Base.transaction do 
      # 高速削除
      ActiveRecord::Base.connection.execute("TRUNCATE TABLE react_crud_data;")
      # 高速挿入
      ActiveRecord::Base.connection.execute("INSERT INTO react_crud_data SELECT * FROM react_crud_data_bks;")
    end  
  
    redirect_to root_path
  end
    
  def create
    @datum = ReactCrudDatum.new(datum_params)

    respond_to do |format|
      if @datum.save
        format.json { render json: {registration: "Ajaxによるデータの登録が成功しました。", 
                                    id:  @datum.id, name:  @datum.name, comment:  @datum.comment, updated_at:  @datum.updated_at} }
      else
        format.json { render json: {registration: "Ajaxによるデータの登録が失敗しました。", 
                                    id:  "error"} }
      end
    end
  end
  
  def update
    respond_to do |format|
      if @datum.update(datum_params)
        format.json { render json: {registration: "Ajaxによるデータの更新が成功しました。"} }
      else
        format.json { render json: {registration: "Ajaxによるデータの更新が失敗しました。"} }
      end
    end
  end  
  
  def destroy
    respond_to do |format|
      if @datum.destroy
        format.json { render json: {registration: "Ajaxによるデータの削除が成功しました。"} }
      else
        format.json { render json: {registration: "Ajaxによるデータの削除が失敗しました。"} }
      end
    end
  end
    
  private
  
    def set_datum
      @datum = ReactCrudDatum.find(params[:id])
    end
      
    def datum_params
      # 送信側のJSONの形式を確認する事!
      params.require(:datum).permit(:name, :comment)
    end
end

11.3 モデル

[app/models/react_crud_datum.rb]

class ReactCrudDatum < ApplicationRecord
  validates :name, length: { maximum: 20 }, presence: true
  validates :comment, length: { maximum: 140 },  presence: true
end

11.4 JSX ※React

[app/javascript/packs/react_crud.jsx]

RailsのCSRFトークンにも対応しています。未対応だとAjaxからRailsのAPIを呼び出すと「422 (Unprocessable Entity)」のエラーが表示されて動作しません。

// IE9,10,11対策用
import "react-app-polyfill/ie9"
import 'react-app-polyfill/stable'

// React
import React from 'react'
import ReactDOM from 'react-dom'
import PropTypes from 'prop-types'

// 日時操作
import {format} from 'date-fns'
import ja from 'date-fns/locale/ja'

// IEのFormData対策用
import 'formdata-polyfill'
    
class ReactCurdComponent extends React.Component {

  // ---------------------
  //  コンストラクタ
  // ---------------------
  constructor(props) {    
    super(props);
    
    this.state = {
      error: null,      // エラー
      isLoaded: false,  // ローディングフラグ
      items: [],        // アイテム
      mode: [],         // アイテムのモード(表示/編集)
      name: '',         // 投稿 - 名前
      comment: '',      // 投稿 - コメント 
      status: 'ここに「Ajax」に関するメッセージが表示されます。' 
    };
  }
    
  // ---------------------
  //  Ajax通信(送信用)
  // ---------------------  
  run_ajax(method, url, data){
    
    fetch(url, 
         {
           method: method,
           body: JSON.stringify(data),
           headers:{
            // JSON
            'Content-Type': 'application/json',
            // CSRFトークン
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content') 
           }
         })
      .then(res => res.json())
      .then(
        (result) => {
          this.setState((state) => {           
            state.status = "サーバーからのメッセージ(" + 
                           format(new Date(), 'yyyy年MM月dd日(iiiii) HH:mm:ss', {locale: ja}) +") :" +
                           result.registration;   

            // 新規登録時のみIDなどが返却される
            if(result.id){
              // 失敗
              if(result.id == "error"){
                return {status: state.status}
              // 成功  
              }else{
                // 先頭にアイテムを追加する 
                state.items.unshift({id: result.id,
                                     name: result.name,
                                     comment: result.comment,
                                     updated_at: result.updated_at}
                                   );    
                state.mode.unshift(false)                   
                return {status: state.status, items: state.items, mode:state.mode}
              }
            // 更新/削除
            }else{
              // 更新/削除のエラー制御は行っていないので各自で。
              return {status: state.status}
            }                 
          });    
        },
        (error) => {
          this.setState((state) => {           
            state.status = error.message;     
            return {status: state.status}
          });              
        }
      )
      .catch((error) => {       
          this.setState((state) => {           
            state.status = error.message;     
            return {status: state.status}
          });  
        }
      );   
  }  

  // ---------------------
  //  イベント(独自)
  // ---------------------
  
  // 投稿 - 名前
  handleNameChange(event){
    const value = event.target.value;
    this.setState((state) => {           
      state.name = value;     
      return {name: state.name}
    });    
  }

  // 投稿 - コメント
  handleCommentChange(event){
    const value = event.target.value;
    this.setState((state) => {           
      state.comment = value;     
      return {comment: state.comment}
    });   
  }
  
  // 表示モード/編集モードの切り替え
  handleModeChange(index, event) {
    
    this.setState((state) => {           
      state.mode[index] = !state.mode[index];      
      return {mode: state.mode}
    });    
    event.preventDefault();
  }  
  
  // データの登録
  handleInsert(event){    
    
    this.setState((state) => {      
      if (state.name && state.comment){

        // Ajax
        this.run_ajax("POST",
                      "http://localhost:3000/react_crud_data/" ,
                      {datum: {name: state.name, comment: state.comment}}
                     );
                                                  
        state.name = '';
        state.comment = '';
        
        return {items: state.items}
      }      
    });  
    
    event.preventDefault();    
  }
    
  // データの更新
  handleUpdate(index, id, event){
    
    // フォームデータ
    // ※<input onChange={} />で状態管理を行うとキャンセルができないので<input defaultValue={} />とFormDataを使っています。
    const form_data = new FormData(event.target);

    this.setState((state) => {
      
      const txt_name = form_data.get('txt_name');
      const txt_comment = form_data.get('txt_comment');
      
      if (
          (txt_name && txt_comment) &&
          (!(state.items[index].name == txt_name && 
             state.items[index].comment == txt_comment))
         ){
          
        state.items[index].name = txt_name;
        state.items[index].comment = txt_comment;
        state.items[index].updated_at = new Date();
        
        // Ajax
        this.run_ajax("PUT",
                      "http://localhost:3000/react_crud_data/"  + id ,
                      {datum: {name: txt_name, comment: txt_comment}}
                     );
                     
        return {items: state.items}                     
      }     
    });  
       
    // 表示モードに変更する
    this.handleModeChange(index, event);
  }

  // データの削除
  handleDelete(index, id, event) {
    
    this.setState((state) => {
     
      state.items.splice(index, 1);
      state.mode.splice(index, 1);
      
      // Ajax
      this.run_ajax("DELETE",
                    "http://localhost:3000/react_crud_data/"  + id ,
                    {}
                   );
            
      return {items: state.items, mode:state.mode}
    });    
            
    event.preventDefault();
  }  
  
  // ---------------------
  //  イベント(React)
  // --------------------- 

  // データの読み込み
  componentDidMount() {
    
    // JSONデータの取得
    fetch("http://localhost:3000/react_crud_data/index.json")
      .then(res => res.json()) 
      .then(
        (result) => {
          
          // モードの初期化(全て表示モード)
          const mode =  Array(result.length).fill(false);  
          
          this.setState({
            isLoaded: true,
            items: result,
            mode: mode                      
          });
        },
        (error) => {
          this.setState({
            isLoaded: true,
            error
          });
        }
      )
  }
  
  // ---------------------
  //  メイン
  // ---------------------
  render() {
    const { error, isLoaded, items, mode} = this.state;
    
    // エラー
    if (error) {
      return <div>Error: {error.message}</div>;
      
    // ローディング 
    } else if (!isLoaded) {
      return <div>Loading...</div>;
      
    // 正常動作  
    } else {
      return (
        <div>
          <p />
          <div className="fixed-bottom bg-dark text-white" style={{opacity: 0.55}}>
            <span>&nbsp;&nbsp;</span>
            <span>{this.state.status}</span>
          </div>
          <h3>投稿</h3>
          <p />
          <form onSubmit={this.handleInsert.bind(this)}>
            <input type="text" value={this.state.name} name="txt_name" className="form-control" placeholder="名前"  onChange={this.handleNameChange.bind(this)} />
            <textarea value={this.state.comment} name="txt_comment" className="form-control" rows="5" placeholder="コメントを入力します。" onChange={this.handleCommentChange.bind(this)} />                      
            <input type="submit" value="登録" className="btn btn-primary" />
          </form>        
          <p />
          <h3>一覧</h3>
          <p />        
          <div className="card-columns">
          {items.map((item,index) => { 
                
                // 表示モード
                if (!mode[index]){                
                  return(
                    <div className="card" key={index}> 
                      <div className="card-header">
                        {item.name} <br />{format(new Date(Date.parse(item.updated_at)), 'yyyy年MM月dd日(iiiii) HH:mm:ss', {locale: ja})}
                      </div>
                      <div className="card-body">
                        {item.comment}
                        <br />
                        <br />
                        <form>
                          <div style={{textAlign:"right"}}> 
                            <input type="submit" value="編集" className="btn btn-primary" onClick={this.handleModeChange.bind(this,index)} />
                            &nbsp;&nbsp;
                            <input type="submit" value="削除" className="btn btn-danger" onClick={this.handleDelete.bind(this,index,item.id)} />
                            &nbsp;&nbsp;
                          </div> 
                        </form>
                      </div>
                    </div>
                  );
                  
                // 編集モード  
                }else{
                  return(
                    <div className="card" key={index}> 
                      <form onSubmit={this.handleUpdate.bind(this,index,item.id)}>
                        <div className="card-header">
                          <input type="text" defaultValue={item.name} name="txt_name" className="form-control" />
                        </div>
                        <div className="card-body">
                          <textarea defaultValue={item.comment} name="txt_comment" className="form-control" rows="5" />                      
                        </div>
                        <div style={{textAlign:"right"}}>
                          <input type="submit" value="キャンセル" className="btn btn-secondary" onClick={this.handleModeChange.bind(this,index)} />
                          &nbsp;&nbsp;
                          <input type="submit" value="更新" className="btn btn-primary" />
                          &nbsp;&nbsp;
                        </div>
                        <p />
                      </form>
                    </div>
                  );
                }  
          })}
          </div>
        </div>
      );
    } // end if
  } // end render
}

document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(
    <ReactCurdComponent />,
    document.getElementById('root')
  )
})

これで、プロジェクトが完成です。動作しない場合はソース一式Rails6プロジェクトの各種初期設定を参照して下さい。

12. 最後に

このサンプルプロジェクトはReactで初めての作品です。

動作確認はChrome、FireFox、Microsoft Edge、IE11です。恐らくマックさんのブラウザも動作するはずです。

個人的にはJavaScriptは好きな部類なのでReact、Vue.jsなどでしばらく遊びます。また、今回のサンプルはエラー処理は完全ではないので、運用する場合は各自で対処して下さい。






関連記事



公開日:2020年03月04日 最終更新日:2020年03月17日
記事NO:02819