Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails 7とReactによるCRUDアプリ作成チュートリアル(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

React logo is licensed under Creative Commons — Attribution 4.0 International — CC BY 4.0.

Rails 7とReactによるCRUDアプリ作成チュートリアル(翻訳)

ほとんどのWebアプリケーションでは、何らかの形式でデータを永続化する必要があります。これは、サーバーサイド言語で作業する場合はシンプルにやれるのが普通です。しかし、そこにフロントエンドのJavaScriptフレームワークも加わってくると、少しややこしくなり始めます。

本チュートリアルでは、Ruby on RailsでJSON APIを構築して、そのAPIと通信する完全なReactフロントエンドをコーディングする方法を紹介します。ここで作成するアプリは、学術イベントのリストを作成・管理する「イベントマネージャ」です。

このアプリでは、基本的なCRUD機能に、datepicker(日付選択ボックス)や絞り込みなどの機能をいくつか追加します。

最終的なアプリは以下のような外観になります。

イベントマネージャにflashメッセージが表示されている

本チュートリアルの完全なコードはGitHubで参照できます。

jameshibbard/react-rails-crud-app - GitHub

🔗 前提条件

本チュートリアルを進めるには、自分のシステムにRubyとNodeがインストールされている必要があります。Rubyについては、公式サイトから自分のシステムに合う公式バイナリをダウンロードするか、rbenvなどのバージョン管理ソフトを使ってインストールします。

rbenv/rbenv - GitHub

Nodeについても同様に、公式サイトから自分のシステムに合う公式バイナリをダウンロードするか、nvmなどのバージョン管理ソフトを使ってインストールします。

creationix/nvm - GitHub

筆者は、RubyとNodeのどちらについてもバージョン管理ソフトを使うことをおすすめします。セットアップも簡単ですし、複数バージョンのRubyやNodeを管理しやすくなります。また、RubyやNodeのインストールで管理権限が不要になるので、パーミッションの問題を解決するうえでも有用です。

本チュートリアルでは、Ruby 3.1とNode 16(最新のLTS)を使います。私のOS環境はLinux Mintなので、ターミナルのコマンドは*nix向けに統一します。

🔗 利用する技術スタック

このようなアプリを構築する方法は1種類ではなく、さまざまな方法があります。本セクションでは、筆者が用いたライブラリの概要と、選択した技術について解説します。

筆者は以下のライブラリを使っています。

データベースはSQLiteにしました。セットアップが最小限で済むのと、Railsの新規アプリではデフォルトでSQLiteが使われるからです。

Reactアプリはesbuildでバンドルしていますが、Webpackerの後継であるShakapackerのセットアップ方法も示します。

現在のReactコミュニティのトレンドを反映するために、本チュートリアルでは(クラスベースのコンポーネントではなくフック関数コンポーネントを使うことにします。なお、クラスベースのコンポーネント版チュートリアルを読んでみたい方は以下の旧版をどうぞ。

参考: How to Create a Simple CRUD App with Rails and React (Class-based Version) · James Hibbard

その他には、パッケージと依存関係を最小限に絞るよう努めました。たとえば、パッケージマネージャには(yarnではなく)npmを使い、Ajaxリクエストの作成には(Axiosなどのパッケージではなく)Fetch APIを用いています。

最後に、本チュートリアルのReactアプリは、Railsアプリのapp/javascript/ディレクトリに置かれます。RailsアプリとReactアプリを別プロジェクトにすることも可能ですが、この規模のアプリなら、どちらかのプロジェクトを他方のプロジェクトに相乗りさせる方が好みです。

訳注

Railsに慣れていない方は以下から読み始めることをおすすめします。

🔗 JavaScriptバンドラーを選ぶ

Railsアプリを新規作成するコマンドを実行する前に、RailsでJavaScriptをどう扱うかを決めておかなければなりません。

最近この方面ではいろんなことが起きていますが、その中でもエキサイティングなのがimportmapの登場です。importmapはRails 7のデフォルトです。

importmapはその名の通り、JavaScriptモジュールをCDNなどから直接インポートし、これらのインポートをバージョン管理下にあるダイジェストファイルにマップします。これにより、トランスパイルやビルドのステップを必要とせずにJavaScriptアプリケーションを構築できるようになります。

惜しいことに、Reactアプリの構築にはJSXのコンパイルが必要なので、importmapによるアプローチは完全ではありません。ここで登場するのが、jsbundling-railsというRails向けJavaScriptバンドラーです。jsbundling-railsは、「esbuild」「rollup.js」「webpack」のいずれかを用いてJavaScriptをバンドルし、アセットパイプラインで配信できるようにするgemです。3つの選択肢のうちRailsコミュニティで最もアツいのはesbuildなので、本チュートリアルではesbuildを使うことにします。

ただし、本チュートリアルではShakapackerによるセットアップ方法も紹介します。Shakapackerは、現在は開発が終了したWebpacker gemの後継であり、「webpackビルドシステムのラッパー」「Webpack設定」「合理的なデフォルト設定のセット」を提供します。

esbuildとShakapackerにはいくつかの違いがあります。esbuildはかなり軽量ですが、Shakapackerほど高機能ではありません。たとえば、esbuildにはHMR機能がなく、code splittingも現在作業中です。ES6以降の構文をES5に変換する機能は、esbuildではサポートされていませんが、ShakapackerはBabelを利用しているので可能です。

現在のRailsにおけるJavaScriptの扱いについて詳しく知りたい方は、Railsの作者であるDHHの以下の動画やブログ記事をどうぞ。

🔗 Railsアプリを新規作成する

最初に、Railsをインストールしてバージョンを確認します。

gem install rails
rails -v
=> 7.0.2.3

次に、JavaScriptバンドラーとしてesbuildまたはShakapackerを選び、それぞれの指示に沿ってセットアップします。

🔗 esbuildの場合

(JavaScriptバンドルを軽量にしたい場合は、このセクションに沿ってセットアップを進めてください。この場合HMR(Hot Module Replacement)やES6+構文の変換は利用できません。)

以下のようにRailsプロジェクトを新規作成します。

rails new event-manager -j esbuild

インストーラを実行したら、アプリのディレクトリに移動してReactをインストールします。

cd event-manager
npm i react react-dom

プロジェクトのルートディレクトリにあるpackage.jsonファイルを開くと、アプリをビルドするスクリプトがあるのがわかります。以下のように、app/javascriptディレクトリを監視するスクリプトを2行目に追加し、ファイル変更が検出されたらすべてを再バンドルするようにしましょう。

// package.json
"scripts": {
  "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --loader:.js=jsx",
  "watch": "esbuild app/javascript/*.* --watch --bundle --outdir=app/assets/builds --loader:.js=jsx"
}

上のスクリプトでは、--loader:.js=jsxによって.jsファイル内でJSX構文を利用可能にするようesbuildに指示しています。ファイルの拡張子を.jsxにすることでも可能です。

最後に、プロジェクトファイルのProcfile.devファイルを開いて以下のように変更します。

web: bin/rails server -p 3000
js: npm run watch

次の「Hello World Reactアプリを作成する」までスキップしてください。

🔗 Shakapackerの場合

(HMRやES6+構文の変換を含むフル機能のJavaScriptバンドラーを使いたい場合は、このセクションに沿ってセットアップを進めてください。)

以下のようにRailsプロジェクトを新規作成します。

rails new event-manager --skip-javascript

アプリのディレクトリに移動して、GemfileにShakapackerを追加します。

bundle add shakapacker --strict

Shakapackerは、Webpackerと同様にyarnに依存するので、yarnを利用できるようにします。

npm i -g yarn

次に以下のコマンドを実行します。

./bin/bundle install
./bin/rails webpacker:install
yarn add react react-dom @babel/preset-react

原注

Shakapackerの最新バージョンには、rails webpacker:installを実行したときにWebpackerのコンフィグファイルを見つけられなくなるバグがあります。

この問題が発生したときは、bundle add shakapacker --version "6.2.1" --strictのように以前のバージョンのShakapackerを指定することで回避できます。

package.jsonを開いて、@babel/preset-reactを追加します。

// package.json
"babel": {
  "presets": [
    "./node_modules/shakapacker/package/babel/preset.js",
    "@babel/preset-react"
  ]
},

🔗 Hello World Reactアプリを作成する

indexアクションを持つsiteコントローラを作成します。このコントローラからReactアプリを配信します。

rails g controller site index

app/views/site/index.html.erbの内容を以下で置き換えます。

<!-- app/views/site/index.html.erb -->
<div id="root"></div>

次にapp/javascript/components/ディレクトリの下にAppというReactアプリケーション(App.js)を作成します。

mkdir app/javascript/components
touch app/javascript/components/App.js

app/javascript/application.jsに以下のコードを追加します。

// app/javascript/application.js
// (省略)
import React from 'react';
import { createRoot } from 'react-dom/client';
import HelloMessage from './components/App';

const container = document.getElementById('root');
const root = createRoot(container);

document.addEventListener('DOMContentLoaded', () => {
  root.render(<HelloMessage name="World" />);
});

ここではHelloMessageコンポーネントをインポートして、上のdiv要素をレンダリングします。

app/javascript/components/App.jsに以下のコードを追加します。

// app/javascript/components/App.js
import React from 'react';
const HelloMessage = ({ name }) => <h1>Hello, {name}!</h1>;
export default HelloMessage;

最後に、config/routes.rbにrootルーティングを追加します。

# config/routes.rb
Rails.application.routes.draw do
  root to: 'site#index'
end

Railsサーバーを起動します。
esbuildを使っている場合は、プロジェクトのルートディレクトリで./bin/devを実行します。
Shakapackerを使っている場合は、ターミナルでRailsサーバーを起動しておいてから、別ターミナルで以下のようにwebpacker-dev-serverを起動します。

rails s
./bin/webpacker-dev-server

これでhttp://localhost:3000/をブラウザで開けば、Reactアプリの"Hello, World!"メッセージが表示されるはずです。

🔗 SQLiteのトラブルシュート

OSによっては、RailsからSQLiteに正しくアクセスするために追加ライブラリをいくつかインストールする必要があるでしょう。

macOSの場合は、SQLiteプレインストールされているようなので、それ以上のインストールは不要です。

Linuxの場合(訳注: およびDockerで作業する場合)は、libsqlite3-devのインストールが必要になるでしょう。

sudo apt-get install libsqlite3-dev

Windowsの場合は、以下のStack Overflowの回答に沿ってSQLiteバイナリをインストールする必要があるでしょう。

参考: windows - Install sqlite3 for Ruby on Rails - Stack Overflow

問題が生じたら、エラーメッセージをググってみてください。

🔗 Hot Module Replacement(HMR)

本チュートリアルに沿って進める限りでは、esbuildとShakapackerの主な違いは、プロジェクトファイルを変更したときの挙動にあります。

esbuildの場合はすべてを再バンドルしますが、更新を確かめるにはブラウザを手動で更新する必要があります。以下のissueを見る限り、esbuildでは、HMRをサポートする予定はなさそうですが、何らかのライブリロード(コードの更新時にブラウザを自動で読み込む: ホットリロードとも)ツールを併用できます

参考: How to integrate react-refresh in esbuild to implement hmr ? · Issue #645 · evanw/esbuild

この点に関心のある方は、以下の記事をどうぞ。

参考: Live reloading with Ruby on Rails and esbuild - DEV Community 👩‍💻👨‍💻

Shakapackerの場合はそれ自身にライブリロード機能があるので、すぐに利用できます。ShakapackerではHMRも利用できるので、ページのステートを維持しながらページの変更部分だけが自動的に更新されます。これは間違いなく便利ですが、依存関係が大幅に増加するというコストを伴います。

esbuildとShakapackerのどちらを使うかを決める前に、どちらのバンドラーが自分と自分が構築するアプリでベストなのかを検討する時間を設けてください。

🔗 ShakapackerのHMRを有効にする

ShakapackerでReactアプリのHMRを有効にするには、少し設定が必要です。esbuildをお使いの方はここをスキップして次の「APIを構築する」へ進んでください。

最初に、config/webpacker.ymlファイルのhmrを trueに設定します。

次に、config/webpack/webpack.config.jsを以下のような感じに変更します。

// config/webpack/webpack.config.js
const { webpackConfig, inliningCss } = require('shakapacker');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const isDevelopment = process.env.NODE_ENV !== 'production';

if (isDevelopment && inliningCss) {
  webpackConfig.plugins.push(
    new ReactRefreshWebpackPlugin({
      overlay: {
        sockPort: webpackConfig.devServer.port,
      },
    })
  );
}

module.exports = webpackConfig;

react-refreshパッケージと@pmmmwh/react-refresh-webpack-pluginをyarnでインストールします。

yarn add --dev react-refresh @pmmmwh/react-refresh-webpack-plugin

最後に、package.jsonにある以下のBabel設定を削除します。

- "babel": {
-   "presets": [
-     "./node_modules/shakapacker/package/babel/preset.js",
-     "@babel/preset-react"
-   ]
- },

次に、プロジェクトのルートディレクトリに以下の内容のbabel.config.jsファイルを作成します。

// babel.config.js
module.exports = function (api) {
  const defaultConfigFunc = require('shakapacker/package/babel/preset.js')
  const resultConfig = defaultConfigFunc(api)
  const isDevelopmentEnv = api.env('development')
  const isProductionEnv = api.env('production')
  const isTestEnv = api.env('test')

  const changesOnDefault = {
    presets: [
      [
        '@babel/preset-react',
        {
          development: isDevelopmentEnv || isTestEnv,
          useBuiltIns: true
        }
      ]
    ].filter(Boolean),
    plugins: [
      isProductionEnv && ['babel-plugin-transform-react-remove-prop-types',
        {
          removeImport: true
        }
      ],
      process.env.WEBPACK_SERVE && 'react-refresh/babel'
    ].filter(Boolean),
  }

  resultConfig.presets = [...resultConfig.presets, ...changesOnDefault.presets]
  resultConfig.plugins = [...resultConfig.plugins, ...changesOnDefault.plugins ]

  return resultConfig
}

サーバーを再起動してブラウザ画面を更新すると、ReactアプリでHMRが有効になります🚀。

🔗 APIを構築する

以下のコマンドでEventモデルを生成します。

./bin/rails g model Event \
event_type:string \
event_date:date \
title:text \
speaker:string \
host:string \
published:boolean

データベースのマイグレーションを実行します。

.bin/rails db:migrate

次に、モデルにいくつかのテストデータをseedします。これを行うには、db/seeds/events.jsonファイルを作成して、リポジトリにあるevents.jsonの内容を追加します。

続いて、db/seeds.rbに以下を追加します。

# db/seeds.rb
json = ActiveSupport::JSON.decode(File.read('db/seeds/events.json'))
json.each do |record|
  Event.create!(record)
end

./bin/rails db:seedを実行し、./rails cでRailsコンソールを起動してデータが読み込まれたことを確認します。

./bin/rails c
Loading development environment (Rails 7.0.2.3)
irb(main):001:0> Event.all.count
   (1.7ms)  SELECT sqlite_version(*)
  Event Count (0.2ms)  SELECT COUNT(*) FROM "events"
=> 6

🔗 コントローラ

次は、APIへのリクエストに応答するEventsコントローラを作成します。名前空間を切るつもりなので、このコントローラは独自ディレクトリの下に保存します。これによってコードが整理されて扱いやすくなり、このAPI独自のルーティングセットを作成できるようになります。

mkdir app/controllers/api
touch app/controllers/api/events_controller.rb

app/controllers/api/events_controller.rbに以下のコードを追加します。

# app/controllers/api/events_controller.rb
class Api::EventsController < ApplicationController
  before_action :set_event, only: %i[show update destroy]

  def index
    @events = Event.all
    render json: @events
  end

  def show
    render json: @event
  end

  def create
    @event = Event.new(event_params)

    if @event.save
      render json: @event, status: :created
    else
      render json: @event.errors, status: :unprocessable_entity
    end
  end

  def update
    if @event.update(event_params)
      render json: @event, status: :ok
    else
      render json: @event.errors, status: :unprocessable_entity
    end
  end

  def destroy
    @event.destroy
  end

  private

  def set_event
    @event = Event.find(params[:id])
  end

  def event_params
    params.require(:event).permit(
      :id,
      :event_type,
      :event_date,
      :title,
      :speaker,
      :host,
      :published,
      :created_at,
      :updated_at
    )
  end
end

これらは、APIのCRUD機能を構成する基本的なメソッドです。このコードについては深く掘り下げないので、問題なく追えるコードになっていることを願っています。Railsが初めてで、API構築について詳しく知りたい方は、以下の記事をどうぞ。

参考: How to Create a Rails Backend API | by Jackson Chen | Geek Culture | Medium

コントローラ関連の最後の作業として、app/controllers/application_controller.rbファイルのクロスサイトリクエストフォージェリ(CSRF)の設定を変更する必要があります。

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :null_session
end

Railsにはクロスサイトリクエストフォージェリ攻撃から保護する機能が組み込まれているので、この変更が必要になります。Railsのデフォルトでは、POST・PUT・PATCH・DELETEリクエストごとに一意のトークンを生成して真正性を検証します。トークンが見つからない場合は例外を発生します。

しかし、ここではシングルページアプリ(SPA)を作成しているので、新鮮なトークンは最初しか提供されません。つまり、この振る舞いを変更する必要があります。

上のコードでは、CSRFトークンが提供されない場合はRailsが空セッションで応答します。これにより、他のスクリプトが認証済みセッションを悪用するのを防止できます。これについて詳しくは、以下の記事をどうぞ。

🔗 ルーティング

最後にconfig/routes.rbのルーティングを手直ししましょう。このコントローラへのルーティングは、Api名前空間の中にあることを考慮する必要があります。これはnamespaceメソッドで行います。

# config/routes.rb
Rails.application.routes.draw do
  root to: 'site#index'

  namespace :api do
    resources :events, only: %i[index show create destroy update]
  end
end

この時点で、http://localhost:3000/api/eventsなどのさまざまなエンドポイントにアクセスして、APIと通信できるかどうかを確かめてください。

APIのテストにはPostmanを使ってもよいでしょう。Postmanで新しいイベントを作成する方法の概略を以下に示します1

Postmanで、リクエストの種別としてPOSTメソッドを選び、URLに http://localhost:3000/api/eventsを入力します。次に「Header」タブをクリックしてContent-Typeキーとapplication/json値を入力します。「Body」タブをクリックし、ドロップダウンメニューの「raw」を選んで以下を入力します。

{
  "event": {
    "event_type": "Colloquium",
    "event_date": "2022-07-21",
    "title": "Investigating the Battle of Hastings",
    "speaker": "Sarah Croix",
    "host": "Jim Bradbury",
    "published": false
  }
}

次に「Send」ボタンをクリックすると、以下のようなレスポンスが表示されるはずです。

PostmanでRails APIをテストする

Postmanの代わりに、以下のようにcurlでも同じことができます。

curl --location --request POST 'http://localhost:3000/api/events'\
--header 'Content-Type: application/json'\
--data-raw '{
  "event": {
    "event_type": "Colloquium",
    "event_date": "2022-07-21",
    "title": "Investigating the Battle of Hastings",
    "speaker": "Sarah Croix",
    "host": "Jim Bradbury",
    "published": false
  }
}'

先に進む前に、Railsコンソールか http://localhost:3000/api/events でイベントが作成されたことを確認しておいてください。

🔗 イベントマネージャをscaffoldで作成する

次に、RailsアプリのUIをどう構成するかを考える必要があります。ここでは、最初に<Editor>コンポーネントを作成し、その中に以下の子コンポーネントを1個ずつ含めることにします。

  • <Header>コンポーネント: アプリのタイトルを表示する
  • <EventList>コンポーネント: イベントのリストを表示する
  • <Event>コンポーネント: 個別のイベントを表示する
  • <EventForm>コンポーネント: イベントの作成と編集を行う

全体像は以下のようになります。

Reactアプリのワイヤフレーム

🔗 イベントをフェッチする

最初に、本セクションで必要なファイルを作成しましょう。

touch app/javascript/components/Editor.js
touch app/javascript/components/Header.js
touch app/javascript/components/EventList.js

原注

Reactコンポーネントはすべてapp/javascript/componentsディレクトリの下に置かれるので、ここからはReactコンポーネントの完全なパスを省略します。

次に、Reactのprop-typesパッケージをインストールします。これは、コンポーネントプロパティで期待される型を記述して、渡される値のデータ型が正しくなるようにします。

npm i prop-types

原注

Shakapackerを使っている場合は、以後yarnで依存関係をインストールすることをお忘れなく。この場合はyarn add prop-typesというコマンドになります。

app/javascript/application.jsを以下のように変更します。

// app/javascript/application.js
// (省略)
import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './components/App';

const container = document.getElementById('root');
const root = createRoot(container);

document.addEventListener('DOMContentLoaded', () => {
  root.render(
    <StrictMode>
      <App />
    </StrictMode>
  );
});

原注

esbuildを使っている場合は、app/javascript/application.jsの冒頭にimport "@hotwired/turbo-rails"import "./controllers"の行も追加されます。この2行は、Hotwireのコア部分を構成するTurboStimulusに関連します。これらはReactアプリケーションには関係ありませんが、消さずに残しておくことをおすすめします。

上のコードでは、<StrictMode>コンポーネント内に<App>コンポーネントがラップされていることもわかります。これは何のUIも表示しませんが、developmentモードでその子孫コンポーネントのチェックと警告を有効にする追加のヘルパーコンポーネントです。詳しくは以下をご覧ください。

参考: strict モード – React

🔗 Reactのフックでデータをフェッチする

これでReactアプリを構築できるようになりました。最初は、ここで必須となるApp.jsから手がけます。これは<Editor>コンポーネントをレンダリングします。

// app/javascript/components/App.js
import React from 'react';
import Editor from './Editor';

const App = () => <Editor />;

export default App;

次は、Editor.jsに以下のコードを追加します。

// app/javascript/components/Editor.js
import React, { useState, useEffect } from 'react';
import Header from './Header';
import EventList from './EventList';

const Editor = () => {
  const [events, setEvents] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await window.fetch('/api/events');
        if (!response.ok) throw Error(response.statusText);
        const data = await response.json();
        setEvents(data);
      } catch (error) {
        setIsError(true);
        console.error(error);
      }

      setIsLoading(false);
    };

    fetchData();
  }, []);

  return (
    <>
      <Header />
      {isError && <p>Something went wrong. Check the console.</p>}

      {isLoading ? <p>Loading...</p> : <EventList events={events} />}
    </>
  );
};

export default Editor;

ここで行っていることが少し多いので、個別に見ていきましょう。ここで行いたいことは、基本的にはAPIに接続してイベントリストを取得し、それを<EventList>に渡してページ上で表示できるようにことです。

最初に、useStateフックを用いて、ステート内の3つの変数(eventsisLoadingisError)と、それらの変数に値を設定する関数を宣言します。変数には初期値もいくつか代入しておきます。

次はuseEffectフックを用いてデータのフェッチを処理します。useEffectには空配列を第2引数として渡しているので、このコンポーネントがレンダリングされると1回実行されます。この関数は、クラスベースのコンポーネントにおけるcomponentDidMountと似ています。

useEffectフックの内部で宣言しているfetchData関数は、/api/eventsエンドポイントにFetch APIでアクセスします。この関数が有効なJSONレスポンス(イベントのリスト)を返したら、それをステートのevents変数に保存します。

発生する可能性のあるエラーをすべてハンドリングできるように、データのフェッチは try... catchブロック内で行われます。ここでは、レスポンスが404や500の場合であっても、fetch()から返されるPromiseがHTTPエラーステータスに基づいて拒否されることがない点にご注意ください。そのため、Response.okプロパティを調べてすべてのエラーを手動チェックしなければなりません。詳しくは以下の記事をどうぞ。

参考: Handling Failed HTTP Responses With fetch()

データのフェッチが完了したら、isLoading変数をfalseに設定します。

原注

この読み込みの効果を実際に確かめたいときは、EventsControllerindexメソッドにsleep 5を追加してください。

フック内で最後に行うのは、fetchData関数の呼び出しです。useEffectに渡すコールバック関数にはsyncを指定できないので、ここでは別の関数が必要です。

最後にJSXをいくつか返します。
<Header>コンポーネントはこの後まもなく宣言しますが、「エラーメッセージ」「読み込み中メッセージ」「<EventList>コンポーネント(イベントリストを渡す)」のいずれかで構成されます。
<Editor>コンポーネントは、上で宣言したisError変数と isLoading変数に基づいて、どちらをレンダリングするかを判定します。

原注

Reactのフックを初めて使う人は、理解を進めるためにSitePointの以下の記事を読んでおくことをおすすめします。

参考: React Hooks: How to Get Started & Build Your Own - SitePoint

Reactのフックを用いてデータをフェッチする仕組みを詳しく知りたい方は、Robin Wieruchによる以下のチュートリアルをおすすめします。

参考: How to fetch data with React Hooks

Header.jsは以下のとおりです。

// app/javascript/components/Header.js
import React from 'react';

const Header = () => (
  <header>
    <h1>Event Manager</h1>
  </header>
);

export default Header;

ここではヘッダー要素をレンダリングしているだけで、大したことは行っていません。

EventList.jsに以下を追加します。

// app/javascript/components/EventList.js
import React from 'react';
import PropTypes from 'prop-types';

const EventList = ({ events }) => {
  const renderEvents = (eventArray) => {
    eventArray.sort((a, b) => new Date(b.event_date) - new Date(a.event_date));

    return eventArray.map((event) => (
      <li key={event.id}>
        {event.event_date}
        {' - '}
        {event.event_type}
      </li>
    ));
  };

  return (
    <section>
      <h2>Events</h2>
      <ul>{renderEvents(events)}</ul>
    </section>
  );
};

EventList.propTypes = {
  events: PropTypes.arrayOf(PropTypes.shape({
    id: PropTypes.number,
    event_type: PropTypes.string,
    event_date: PropTypes.string,
    title: PropTypes.string,
    speaker: PropTypes.string,
    host: PropTypes.string,
    published: PropTypes.bool,
  })).isRequired,
};

export default EventList;

このコンポーネントは、イベントオブジェクトの配列をプロパティ(events)として受け取り、ソート済みリストとしてレンダリングする役割を担います。これを行うrenderEventsは、配列を日付の降順でソートしてから、各イベントのリスト項目をレンダリングします。

ここでご注意いただきたいのは、いくつかのプロパティバリデーションも実装してeventspropがオブジェクトの配列になるようにしていることと、その配列内のオブジェクトが特定のプロパティセットを持つようにしていることです。ここでは、eventsプロパティと、すべてのオブジェクトプロパティが必須であることを指定しています。その結果、developmentモードでは何かが不足していたり型が誤っている場合にエラーが発生するようになっています。

ブラウザで http://localhost:3000/ を開くと以下のように全イベントのリストが表示されるはずです。エキサイティングですね。

イベントマネージャのイベントリスト

🔗 開発用ツールを追加する

今はJavaScriptを書いているので、この辺で便利な開発ツールをいくつか追加し、コードの品質を担保しましょう。

🔗 ESLint

ESLintは、JavaScriptコードでよくあるエラーや問題のあるパターンを検出するツールです。JavaScript開発者が愛用するツールの中でも、コードの品質に関してESLintの便利さはトップクラスです。ESLintは以下のようにインストールできます。

npm i -D eslint

次はAirbnb configをプロジェクトに追加します。これは、AirbnbのJavaScriptスタイルガイドに対応するルールセットを提供します。

npm i -D eslint-config-airbnb
# yarnの場合
yarn add --dev eslint eslint-config-airbnb

最後に、残りの依存関係を調べます。

npm info "eslint-config-airbnb@latest" peerDependencies

上で以下の結果が出力されます。

{
  eslint: '^7.32.0 || ^8.2.0',
  'eslint-plugin-import': '^2.25.3',
  'eslint-plugin-jsx-a11y': '^6.5.1',
  'eslint-plugin-react': '^7.28.0',
  'eslint-plugin-react-hooks': '^4.3.0'
}

上で得た残りの4つのパッケージをpackage.jsonのdevDependenciesセクションに追加し(訳注: '"に置き換えます)、npm i(またはyarn install)を実行してインストールします。

// package.json
// (略)
"devDependencies": {
  ...
  "eslint-plugin-import": "^2.25.3",
  "eslint-plugin-jsx-a11y": "^6.5.1",
  "eslint-plugin-react": "^7.28.0",
  "eslint-plugin-react-hooks": "^4.3.0"
}

プロジェクトのルートディレクトリで以下の内容の.eslintrc.jsファイルを作成します。

// .eslintrc.js
module.exports = {
  root: true,
  extends: ['airbnb', 'airbnb/hooks'],
  rules: {
    'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }],
    'react/function-component-definition': [
      1,
      { namedComponents: 'arrow-function' },
    ],
    'no-console': 0,
    'no-alert': 0,
  },
};

このファイルは、インストールしたAirbnbのルールセットを使ってReactフックのルールのlintを有効にするようESLintに指示します。また、拡張子が.jsのファイルにJSXコードを含められるようにする指定、consolealertで警告を出さないようにする指定、そして関数コンポーネントでアロー関数(=>)構文を利用できるようにする設定も行います。

関数コンポーネントで別の種類の関数を強制したい場合は、以下の設定方法をどうぞ。

参考: eslint-plugin-react/function-component-definition.md at master · jsx-eslint/eslint-plugin-react

これで、ターミナルからESLintを実行できます。

./node_modules/.bin/eslint app/javascript

以下のようにpackage.jsonファイルのnpmスクリプトとしても実行できます。

"scripts": {
  "lint": "eslint app/javascript"
},

しかしベストな結果を得るために、自分のエディタにlintを統合したくなるでしょう。私の場合はSubline Text 3とSublimeLinterSublimeLinter-eslintSublimeJsPrettierで良好な結果を得ています。その他に、Prettierとコンフリクトする可能性のあるlintルールをオフにできるeslint-config-prettierも使っています。

VSCodeをお使いの方なら、Wes Bosによる以下の動画をどうぞ。

🔗 ReactのDeveloper Tools

しばらくツールの話題が続きましたが、あとReactのDeveloper Toolsについても少しだけお時間をいただきたいと思います。ReactのDeveloper Toolsを使うと、Reactのコンポーネント階層(コンポーネントのプロパティやステートなど)をブラウザ拡張で調べられるようになります。

React Developer Tools

本チュートリアルの手順に忠実に沿って進めていれば、React Developer Toolsの出番はあまりないと思いますが、チュートリアルから外れて背後のReactの振る舞いを理解するうえでとても役に立つでしょう。

イベントを表示する

次はイベントをクリッカブルにして、ユーザーがイベントを選択したらイベントの詳細を表示するようにしてみましょう。そのためにはReact Routerが必要です。React Routerは、現在のイベントを反映しているURLを変更して、イベント情報を取り出せるようにします。

React Router

React Routerは以下のようにインストールできます。

npm i react-router-dom@6

Yarnの場合は以下のようにインストールします。

yarn add react-router-dom@6

ご覧のように、最新のReact Router(バージョン6)を使っています。React Routerはバージョン5から6で大半が書き換えられた点にご注意ください。React Router 6について詳しく知りたい方は、Robin Wieruchによる以下のチュートリアルをおすすめします。

参考: React Router 6 Tutorial

手始めに、config/routes.rbのルーティングを用意しておきましょう。

# config/routes.rb
Rails.application.routes.draw do
  root to: redirect('/events')

  get 'events', to: 'site#index'
  get 'events/new', to: 'site#index'
  get 'events/:id', to: 'site#index'
  get 'events/:id/edit', to: 'site#index'

  namespace :api do
    resources :events, only: %i[index show create destroy update]
  end
end

ブロックの1行目は、http://localhost:3000/events へのrootルーティングですが、これは純粋に美観上の理由です。
しかし次の4行では、Reactアプリで使うルーティングをRailsに知らせていることがわかります。ここは重要です。そうでないと、ユーザーが(ページを更新するなどして)これらのルーティングを直接リクエストしてもRailsが認識できず、404を返してしまいます。
この4行があることで、RailsはReactアプリを単純に提供して、どのビューを表示するかについてはReactに任せられるようになります。

それでは、app/javascript/application.jsに以下のルーティングを追加しましょう。

// app/javascript/application.js
import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './components/App';

const container = document.getElementById('root');
const root = createRoot(container);

document.addEventListener('DOMContentLoaded', () => {
  root.render(
    <StrictMode>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </StrictMode>
  );
});

上のコードは、アプリを<BrowserRouter>コンポーネントにラップし、HTML5のhistry APIでUIをURLと同期します。

App.jsにも少し変更が必要です。

// app/javascript/components/App.js
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import Editor from './Editor';

const App = () => (
  <Routes>
    <Route path="events/*" element={<Editor />} />
  </Routes>
);

export default App;

ここでは<Editor>コンポーネントを直接レンダリングする代わりに<Route>コンポーネントを用いることで、ブラウザでevents/で始まるURLの表示を開始するたびにレンダリングするようにします。

イベントが正しい場所に表示されるようにするには、ルーティングがもうひとつ必要です。<Editor>コンポーネントを以下のように変更します。

// app/javascript/components/Editor.js
// (省略)
import { Routes, Route } from 'react-router-dom';
import Event from './Event';

const Editor = () => {
// (省略)
  return (
    <>
      <Header />
      {isError && <p>Something went wrong. Check the console.</p>}
      {isLoading ? (
        <p>Loading...</p>
      ) : (
        <>
          <EventList events={events} />

          <Routes>
            <Route path=":id" element={<Event events={events} />} />
          </Routes>
        </>
      )}
    </>
  );
};

export default Editor;

<App>コンポーネントのときと同様に、ここでは<Route>コンポーネントを用いており、そのパスは:idに設定されています。これは「dynamic segment」と呼ばれるもので、現在のイベントIDと一致します。

つまり、http://localhost:3000/events/7 のようなURLが与えられると以下の処理が行われます。

  • application.jsが<App>コンポーネント(<BrowserRouter>にラップされている)をレンダリングする
  • <App>コンポーネント内では、<Route>コンポーネントがURLのevents/と一致して、<Editor>コンポーネントをレンダリングする
  • <Editor>コンポーネント内では、<Route>コンポーネントがURLの残りの部分(つまり7)とマッチし、<Event>コンポーネントをレンダリングする(ここにフェッチ済みのイベントリストが渡される)
  • <Event>コンポーネント内では、URLの:idセクションがコードで利用可能になる。

ここでしばらく立ち止まり、上の処理をすべて理解してから次に進みましょう。

🔗 <Event>コンポーネント

次は、イベントを表示する<Event>コンポーネントが必要です。

touch app/javascript/components/Event.js

作成したEvent.jsに以下の内容を追加します。

// app/javascript/components/Event.js
import React from 'react';
import PropTypes from 'prop-types';
import { useParams } from 'react-router-dom';

const Event = ({ events }) => {
  const { id } = useParams();
  const event = events.find((e) => e.id === Number(id));

  return (
    <>
      <h2>
        {event.event_date}
        {' - '}
        {event.event_type}
      </h2>
      <ul>
        <li>
          <strong>Type:</strong> {event.event_type}
        </li>
        <li>
          <strong>Date:</strong> {event.event_date}
        </li>
        <li>
          <strong>Title:</strong> {event.title}
        </li>
        <li>
          <strong>Speaker:</strong> {event.speaker}
        </li>
        <li>
          <strong>Host:</strong> {event.host}
        </li>
        <li>
          <strong>Published:</strong> {event.published ? 'yes' : 'no'}
        </li>
      </ul>
    </>
  );
};

Event.propTypes = {
  events: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.number.isRequired,
      event_type: PropTypes.string.isRequired,
      event_date: PropTypes.string.isRequired,
      title: PropTypes.string.isRequired,
      speaker: PropTypes.string.isRequired,
      host: PropTypes.string.isRequired,
      published: PropTypes.bool.isRequired,
    })
  ).isRequired,
};

export default Event;

ここではReact RouterのuseParamsフックをインポートしています。このフックによって、<Route path>(ここでは:id)でマッチしたカレントURLの動的パラメータを含むオブジェクトにアクセスできるようになります。

次に、JavaScriptの分割代入構文を用いて現在のイベントのIDを取得し、プロパティとして渡されたイベントリストをこのIDでフィルタすることで、表示したいイベントを検索します。

(すべてのイベントではなく)コンポーネントでの表示が必要なイベントだけをコンポーネントに渡したいところですが、これを現在のReact Routerの仕組みでやろうとするとかなり厄介なことになります。
すべてのイベントをReactのコンテクストに貼り付けることも検討しましたが、ただでさえ長い本チュートリアルがますます複雑になるので、ここではやりすぎでしょう。それでも、このような選択肢があることは知っておいてください。

残りのコードは、JSXをいくつか返し、前回と同様にプロパティのバリデーションを行うので、十分わかりやすくなっていることを願っています。

🔗 イベントをクリッカブルにする

最後に、<EventList>コンポーネント内のイベントリストをクリッカブルにします。イベントをクリックすると/events/:idに移動します。

// app/javascript/components/EventList.js
// (省略)
import { Link } from 'react-router-dom';

const renderEvents = (eventArray) => {
  eventArray.sort((a, b) => new Date(b.event_date) - new Date(a.event_date));

  return eventArray.map((event) => (
    <li key={event.id}>
      <Link to={`/events/${event.id}`}>
        {event.event_date}
        {' - '}
        {event.event_type}
      </Link>
    </li>
  ));
};

ここでは、React Routerの<Link>コンポーネントを用いてアプリケーション内を移動するリンクを作成しています。

これで、リンクをクリックすると以下のように正しいイベントが表示されるはずです。

イベントマネージャ: イベントを表示する

🔗 スタイルを追加する

アプリのスタイルがかなり寂しいので、少し手を加えて見栄えを整えましょう。App.cssを作成します。

touch app/javascript/components/App.css

App.cssに以下のスタイルを追加します。

/* app/javascript/components/App.css */
body, html, div, blockquote, img, label, p, h1, h2, h3, h4, h5, h6, pre, ul, ol, li, dl, dt, dd, form, a, fieldset, input, th, td {
  margin: 0;
  padding: 0;
}

ul, ol {
  list-style: none;
}

body {
  font-family: Roboto;
  font-size: 16px;
  line-height: 28px;
}

header {
  background: #f57011;
  height: 60px;
}

header h1, header h1 a{
  display: inline-block;
  font-family: "Maven Pro";
  font-size: 28px;
  font-weight: 500;
  color: white;
  padding: 14px 5%;
  text-decoration: none;
}

header h1:hover {
  text-decoration: underline;
}

.grid {
  display: grid;
  grid-gap: 50px;
  grid-template-columns: minmax(250px, 20%) auto;
  margin: 25px auto;
  width: 90%;
  height: calc(100vh - 145px);
}

.eventList {
  background: #f6f6f6;
  padding: 16px;
}

.eventList h2 {
  font-size: 20px;
  padding: 8px 6px 10px;
}

.eventContainer {
  font-size: 15px;
  line-height: 35px;
}

.eventContainer h2 {
  margin-bottom: 10px;
}

.eventList li:hover, a.active {
  background: #f8e5ce;
}

.eventList a {
  display: block;
  color: black;
  text-decoration: none;
  border-bottom: 1px solid #dddddd;
  padding: 8px 6px 10px;
  outline: 0;
}

.eventList h2 > a {
  color: #236fff;
  font-size: 15px;
  float: right;
  font-weight: normal;
  border-bottom: none;
  padding: 0px;
}

.eventForm {
  margin-top: 15px;
}

label > strong {
  display: inline-block;
  vertical-align: top;
  text-align: right;
  width: 100px;
  margin-right: 6px;
  font-size: 15px;
}

input, textarea {
  padding: 2px 0 3px 3px;
  width: 400px;
  margin-bottom: 15px;
  box-sizing: border-box;
}

input[type="checkbox"] {
  width: 13px;
}

button[type="submit"] {
  background: #f57011;
  border: none;
  padding: 5px 25px 8px;
  font-weight: 500;
  color: white;
  cursor: pointer;
  margin: 10px 0 0 106px;
}

.errors {
  border: 1px solid red;
  border-radius: 5px;
  margin: 20px 0 35px 0;
  width: 513px;
}

.errors h3 {
  background: red;
  color: white;
  padding: 10px;
  font-size: 15px;
}

.errors ul li {
  list-style-type: none;
  margin: 0;
  padding: 8px 0 8px 10px;
  border-top: solid 1px pink;
  font-size: 12px;
  font-weight: 0.9;
}

button.delete {
  background: none !important;
  border: none;
  padding: 0 !important;
  margin-left: 10px;
  cursor: pointer;
  color: #236fff;
  font-size: 15px;
  font-weight: normal;
  text-decoration: none;
}

button.delete:hover {
  text-decoration: underline;
}

h2 a {
  color: #236fff;
  font-size: 15px;
  font-weight: normal;
  margin: 3px 12px 0 12px;
  text-decoration: none;
}

h2 a:hover {
  text-decoration: underline;
}

.form-actions a {
  color: #236fff;
  font-size: 15px;
  margin: 3px 12px 0 12px;
  text-decoration: none;
}

.form-actions a:hover {
  text-decoration: underline;
}

input.search {
  width: 92%;
  margin: 15px 2px;
  padding: 4px 0 6px 6px;
}

.loading {
  height: calc(100vh - 60px);
  display: grid;
  justify-content: center;
  align-content: center;
}

原注

これらのスタイルは、すべてこのアプリで必要なものです。ここでは本チュートリアルが長くならないように、スタイルを1箇所に集約しています。

ここでは、custom resetという小さなCSSリセットとCSS Gridの良さをレイアウトで使っています。CSS Gridに慣れていない方は、以下に良いチュートリアルがありますのでどうぞ。

参考: A Beginners Guide to CSS Grid Layout — Medialoot

このスタイルをApp.jsでインポートします。

// app/javascript/components/App.js
import './App.css';

次に、<Editor>コンポーネントのマークアップを変更します。

// app/javascript/components/Editor.js
// (省略)
return (
  <>
    <Header />
    <div className="grid">
      {isError && <p>Something went wrong. Check the console.</p>}
      {isLoading ? (
        <p className='loading'>Loading...</p>
      ) : (
        <>
          <EventList events={events} />

          <Routes>
            <Route path=":id" element={<Event events={events} />} />
          </Routes>
        </>
      )}
    </div>
  </>
);
// (省略)

<EventList>コンポーネントも以下のように変更します。

// app/javascript/components/EventList.js
// (省略)
return (
  <section className="eventList">
    <h2>Events</h2>
    <ul>{renderEvents(events)}</ul>
  </section>
);
// (省略)

<Event>コンポーネントも以下のように変更します。

// app/javascript/components/Event.js
// (省略)
return (
  <div className="eventContainer">
    <h2> (省略) </h2>
    <ul> (省略) </ul>
  </div>
);
// (省略)

app/views/layouts/application.html.erbファイルを開いてカスタムフォントをいくつか追加します。

<!-- app/views/layouts/application.html.erb -->
<head>
  <title>EventManager</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <%= csrf_meta_tags %>
  <%= csp_meta_tag %>

  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Maven+Pro:wght@400;500;700&family=Roboto:ital,wght@0,300;0,400;0,700;1,400&display=swap" rel="stylesheet">

  <!-- (省略) -->
</head>

🔗 JavaScriptバンドラー固有の設定

利用しているJavaScriptバンドラーに応じて、若干の設定追加が必要です。

🔗 esbuildの場合

esbuildを使っている場合は、app/assets/stylesheets/application.cssファイルをプロジェクトから削除する必要があります。そうしないと、次回Railsサーバーを起動したときに以下の警告が表示されます。

ActionView::Template::Error (Multiple files with the same output path cannot be linked ("application.css")

あるいは、App.cssファイルを作成する代わりに、app/assets/stylesheetsのcssファイルにすべてのCSSを追加してRailsのアセットパイプラインで扱う方法もあります。本チュートリアルではどちらの方法でも大きな違いは生じませんが、平均的なReactアプリではスタイルを指定するReactコンポーネントと同じ階層にCSSファイルを配置するのが理にかなっています。

🔗 Shakapackerの場合

Shakapackerを使っている場合は、CSSファイルを扱うためのパッケージをいくつか追加する必要があります。

yarn add css-loader style-loader mini-css-extract-plugin css-minimizer-webpack-plugin

念のためサーバーを再起動します。

詳しくはShakapackerのドキュメントをどうぞ。

これで、どちらのJavaScriptバンドラーを使っている場合も以下のようにスタイルが追加されるはずです。

よい感じのスタイルが付いたイベントマネージャ

🔗 選択したイベントをハイライトする

次に進む前の最後の微調整として、イベントリストで選択したイベントにスタイルを追加しましょう。これで、現在選択しているイベントがユーザーからひと目でわかるようになります。

必要な作業は、<EventList>コンポーネント内にある<Link>コンポーネントを<NavLink>コンポーネントに置き換えるだけです。

// app/javascript/components/EventList.js
// (省略)
import { Link, NavLink } from 'react-router-dom';

const EventList = ({ events }) => {
  const renderEvents = (eventArray) => {
    eventArray.sort((a, b) => new Date(b.event_date) - new Date(a.event_date));

    return eventArray.map((event) => (
      <li key={event.id}>
        <NavLink to={`/events/${event.id}`}>
          {event.event_date}
          {' - '}
          {event.event_type}
        </NavLink>
      </li>
    ));
  };

// (省略)
};

<NavLink>は特殊な<Link>リンクの一種で、現在アクティブなリンクにCSSのactiveクラスを追加することで、このアプリのCSSでスタイルを付けられるようになります。

🔗 イベントを作成する

CRUDアプリの"R"(Read)機能が動くようになったので、次はイベント作成機能を追加しましょう。

最初は<Editor>コンポーネントに手を加えます。

// app/javascript/components/Editor.js
// (省略)
import EventForm from './EventForm';

const Editor = () => {
  // (省略)

  return (
    <>
      <Header />
      <div className="grid">
        {isError && <p>Something went wrong. Check the console.</p>}
        {isLoading ? (
          <p>Loading...</p>
        ) : (
          <>
            <EventList events={events} />

            <Routes>
              <Route path="new" element={<EventForm />} />
              <Route path=":id" element={<Event events={events} />} />
            </Routes>
          </>
        )}
      </div>
    </>
  );
};
// (省略)

ここでは、pathを"new"に設定した<Route>コンポーネントを1個追加しています。これが現在のURL(つまり/events/new)にマッチすると<EventForm>コンポーネントがレンダリングされ、イベントの追加を(後に編集も)行うフォームが含まれるようになります。

次に、<EventList>コンポーネントにフォーム表示用のリンクを追加しましょう。

// app/javascript/components/EventList.js
// (省略)
const EventList = ({ events }) => {
  const renderEvents = (eventArray) => {
     //(省略)
  };
  // (省略)
  return (
    <section className="eventList">
      <h2>
        Events
        <Link to="/events/new">New Event</Link>
      </h2>
      <ul>{renderEvents(events)}</ul>
    </section>
  );
};
// (省略)

次に、<EventForm>コンポーネントを作成します。

touch app/javascript/components/EventForm.js

EventForm.jsに以下を追加します。

import React from 'react';

const EventForm = () => {
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Submitted');
  };

  return (
    <section>
      <h2>New Event</h2>
      <form className="eventForm" onSubmit={handleSubmit}>
        <div>
          <label htmlFor="event_type">
            <strong>Type:</strong>
            <input type="text" id="event_type" name="event_type" />
          </label>
        </div>
        <div>
          <label htmlFor="event_date">
            <strong>Date:</strong>
            <input type="text" id="event_date" name="event_date" />
          </label>
        </div>
        <div>
          <label htmlFor="title">
            <strong>Title:</strong>
            <textarea cols="30" rows="10" id="title" name="title" />
          </label>
        </div>
        <div>
          <label htmlFor="speaker">
            <strong>Speakers:</strong>
            <input type="text" id="speaker" name="speaker" />
          </label>
        </div>
        <div>
          <label htmlFor="host">
            <strong>Hosts:</strong>
            <input type="text" id="host" name="host" />
          </label>
        </div>
        <div>
          <label htmlFor="published">
            <strong>Publish:</strong>
            <input type="checkbox" id="published" name="published" />
          </label>
        </div>
        <div className="form-actions">
          <button type="submit">Save</button>
        </div>
      </form>
    </section>
  );
};

export default EventForm;

この時点で http://localhost:3000/events/new を開くと以下のようにフォームが表示され、「Save」ボタンをクリックすると、ブラウザのコンソールに"submitted"とログ出力されるはずです。

新規イベントのフォームでコンソールに「Submitted」と表示された様子

🔗 フォームのバリデーション

それでは、published以外のすべてのフィールドに入力されていることを確認するバリデーションを追加しましょう。すべての操作は<EventForm>コンポーネント内で行われます。

変更後の<EventForm>コンポーネントは以下のようになります。

// app/javascript/components/EventForm.js
import React, { useState } from 'react';

const EventForm = () => {
  const [event, setEvent] = useState({
    event_type: '',
    event_date: '',
    title: '',
    speaker: '',
    host: '',
    published: false,
  });

  const [formErrors, setFormErrors] = useState({});

  const handleInputChange = (e) => {
    const { target } = e;
    const { name } = target;
    const value = target.type === 'checkbox' ? target.checked : target.value;

    setEvent({ ...event, [name]: value });
  };

  const validateEvent = () => {
    const errors = {};

    if (event.event_type === '') {
      errors.event_type = 'You must enter an event type';
    }

    if (event.event_date === '') {
      errors.event_date = 'You must enter a valid date';
    }

    if (event.title === '') {
      errors.title = 'You must enter a title';
    }

    if (event.speaker === '') {
      errors.speaker = 'You must enter at least one speaker';
    }

    if (event.host === '') {
      errors.host = 'You must enter at least one host';
    }

    return errors;
  };

  const isEmptyObject = (obj) => Object.keys(obj).length === 0;

  const renderErrors = () => {
    if (isEmptyObject(formErrors)) {
      return null;
    }

    return (
      <div className="errors">
        <h3>The following errors prohibited the event from being saved:</h3>
        <ul>
          {Object.values(formErrors).map((formError) => (
            <li key={formError}>{formError}</li>
          ))}
        </ul>
      </div>
    );
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    const errors = validateEvent(event);

    if (!isEmptyObject(errors)) {
      setFormErrors(errors);
    } else {
      console.log(event);
    }
  };

  return (
    <section>
      {renderErrors()}

      <h2>New Event</h2>
      <form className="eventForm" onSubmit={handleSubmit}>
        <div>
          <label htmlFor="event_type">
            <strong>Type:</strong>
            <input
              type="text"
              id="event_type"
              name="event_type"
              onChange={handleInputChange}
            />
          </label>
        </div>
        <div>
          <label htmlFor="event_date">
            <strong>Date:</strong>
            <input
              type="text"
              id="event_date"
              name="event_date"
              onChange={handleInputChange}
            />
          </label>
        </div>
        <div>
          <label htmlFor="title">
            <strong>Title:</strong>
            <textarea
              cols="30"
              rows="10"
              id="title"
              name="title"
              onChange={handleInputChange}
            />
          </label>
        </div>
        <div>
          <label htmlFor="speaker">
            <strong>Speakers:</strong>
            <input
              type="text"
              id="speaker"
              name="speaker"
              onChange={handleInputChange}
            />
          </label>
        </div>
        <div>
          <label htmlFor="host">
            <strong>Hosts:</strong>
            <input
              type="text"
              id="host"
              name="host"
              onChange={handleInputChange}
            />
          </label>
        </div>
        <div>
          <label htmlFor="published">
            <strong>Publish:</strong>
            <input
              type="checkbox"
              id="published"
              name="published"
              onChange={handleInputChange}
            />
          </label>
        </div>
        <div className="form-actions">
          <button type="submit">Save</button>
        </div>
      </form>
    </section>
  );
};

export default EventForm;

最初に、ステートに2つの変数eventformErrorsを定義します。
event変数はオブジェクトとしていくつかの妥当なデフォルト値で初期化され、formErrorsは空オブジェクトとして初期化されます。

次はhandleInputChange関数です。このフォーム内のすべてのフィールドは「制御された入力」にする(つまりフィールドのステートの維持や設定はReactが行う)ことにします。

ユーザーがどのフィールドの値を変更しても、そのたびにhandleInputChange関数が呼び出されてeventオブジェクトが更新され、フォームへの入力がeventオブジェクトに反映されます。nameなどの変数をオブジェクトのキーとして扱うには、[name]のように角かっこで囲む点にご注意ください。

次はヘルパー関数validateEventisEmptyObjectです。

1つ目のvalidateEvent関数は、eventオブジェクトに対して多くのチェックを実行し、エラーの場合はエラーを含むオブジェクトを返します。
2つ目のisEmptyObject関数は、渡されたオブジェクトにプロパティが存在するかどうかをtrueまたはfalseで返します。

次のrenderErrors関数は、formErrorsオブジェクトが空の場合はnullを返し、そうでない場合は、保存できないという警告とエラーリストをJSXで返します。

最後はhandleSubmit関数を更新して、ユーザー入力をバリデーション(および各フィールドに値が存在していることもチェック)し、不足がある場合はエラーメッセージを、正常な場合は有効なイベントをブラウザコンソールにログ出力します。なお、JSXも少し更新してフォームのすべての入力にonChangeプロパティを追加してあります。

🔗 ヘルパー関数を作成する

<EventForm>コンポーネントがだいぶ肥大化してきたので、この時点で一般的な関数をそれ用のファイルに切り出すのがよいでしょう。

最初に切り出す候補は、いかにもアプリの他の場所でも使われそうなvalidateEvent関数とisEmptyObject関数がよいでしょう。こうすることで、ヘルパー関数がReactから切り離されてテストを書きやすくなります。

この2つの関数を配置するためのファイルを作成しましょう。

mkdir app/javascript/helpers
touch app/javascript/helpers/helpers.js

helpers.jsに以下のコードを追加します。このとき、<EventForm>コンポーネントにある元のvalidateEvent関数とisEmptyObject関数を削除することをお忘れなく。

// app/javascript/helpers/helpers.js
export const isEmptyObject = obj => Object.keys(obj).length === 0;

export const validateEvent = (event) => {
  const errors = {};

  if (event.event_type === '') {
    errors.event_type = 'You must enter an event type';
  }

  if (event.event_date === '') {
    errors.event_date = 'You must enter a valid date';
  }

  if (event.title === '') {
    errors.title = 'You must enter a title';
  }

  if (event.speaker === '') {
    errors.speaker = 'You must enter at least one speaker';
  }

  if (event.host === '') {
    errors.host = 'You must enter at least one host';
  }

  return errors;
}

これを<EventForm>コンポーネントで以下のようにインポートします。

// app/javascript/components/EventForm.js
import { isEmptyObject, validateEvent } from '../helpers/helpers';

これで、入力漏れのあるフィールドを送信しようとすると以下のようなエラーがスタイル付きで表示されるはずです。

イベントマネージャ: フォーム送信エラー

🔗 日付フィールドをdatepickerに変更する

次の作業は、日付フィールドをdatepickerに変更することです。ここではPikadayというパッケージを使います。

最初に、Pikadayをnpmまたはyarnでインストールする必要があります。

# npmの場合
npm i pikaday
# yarnの場合
yarn add pikaday

次に、<EventForm>のReactインポートを以下のように少し変更してからPikadayをインポートします。

// app/javascript/components/EventForm.js
import React, { useState, useRef, useEffect } from 'react';
import Pikaday from 'pikaday';
import 'pikaday/css/pikaday.css';
// (省略)

<EventForm>コンポーネント本文に以下を追加します。

// app/javascript/components/EventForm.js
// (省略)
const EventForm = () => {
  const [event, setEvent] = useState({
    //(省略)
  });
  const [formErrors, setFormErrors] = useState({});

  // 以下を追加する
  const dateInput = useRef(null);
  // (省略)
}

日付フィールドを以下のように変更します。

// app/javascript/components/EventForm.js
// (省略)
<div>
  <label htmlFor="event_date">
    <strong>Date:</strong>
    <input
      type="text"
      id="event_date"
      name="event_date"
      ref={dateInput}
      autoComplete="off"
    />
  </label>
</div>
// (省略)

ここでは日付入力フィールドへの参照をuseRefフックで作成しているので、コードの他の場所からもアクセスできるようになっています。

次はuseEffectフックを追加して、コンポーネントがマウントされたときにdatepickerを初期化する必要があります。

// app/javascript/components/EventForm.js
// (省略)
useEffect(() => {
  const p = new Pikaday({
    field: dateInput.current,
    onSelect: (date) => {
      const formattedDate = formatDate(date);
      dateInput.current.value = formattedDate;
      updateEvent('event_date', formattedDate);
    },
  });

  // クリーンアップ用の関数を返す
  // Reactはアンマウントの前にこれを呼び出す
  return () => p.destroy();
}, []);
// (省略)

この参照のおかげで、Pikadayコンストラクタに渡される設定オブジェクトのfieldプロパティは、datepickerにしたいDOM要素を指すようになります。
onSelectメソッドは、ユーザーが日付を選択したときの動作を決定します。この場合、日付はYYYY-MM-DD形式でフォーマットされ、ステートが保持しているeventオブジェクトが更新されます。

次は、formatDate関数をapp/javascript/helpers/helpers.jsファイル内のヘルパーメソッドとして書きます。この関数は、Dateオブジェクトを受け取ってYYYY-MM-DD形式の文字列を返します。

// app/javascript/helpers/helpers.js
// (省略)
export const formatDate = (d) => {
  const YYYY = d.getFullYear();
  const MM = `0${d.getMonth() + 1}`.slice(-2);
  const DD = `0${d.getDate()}`.slice(-2);

  return `${YYYY}-${MM}-${DD}`;
};

作成したformatDate関数を<EventForm>コンポーネントのインポートに追加することをお忘れなく。

// app/javascript/components/EventForm.js
// (省略)
import { formatDate, isEmptyObject, validateEvent } from '../helpers/helpers';
// (省略)

これで、<EventForm>コンポーネント内でupdateEventメソッドを宣言できるようになります。

// app/javascript/components/EventForm.js
// (省略)
const updateEvent = (key, value) => {
  setEvent((prevEvent) => ({ ...prevEvent, [key]: value }));
};
// (省略)

ここでsetEvent関数を呼び出すときに、関数を引数として渡している点が少し異なることにご注意ください。
このコールバック関数はeventの直前の値を受け取り、それを新しいオブジェクトに展開して、変更されたキーバリューペアを更新します。このオブジェクトはeventの新しい値として返されます。

ここではsetEvent({ ...event, [key]: value })のような形ではなく、上のように書く必要があります。そうしないと、onSelectメソッド内でeventが初期値(つまり空オブジェクト)を指してしまいます。
その理由は、onSelectが宣言されるとeventに対するクロージャを形成し、誤った値をキャプチャするからです。これについて詳しくは以下の記事をどうぞ。

参考: Be Aware of Stale Closures when Using React Hooks

別の方法として、依存配列useEffecteventを追加することでも一応解決できます。ただしこの方法だと、ユーザーがフォームに1文字入力するたびに新しいdatepickerが作成されることになり、理想的とは言えません。これについて詳しくは以下のGitHub issueをどうぞ。

参考: State variable not updating in useEffect callback? · Issue #14066 · facebook/react

最後にhandleInputChange関数を更新して、この新しいメソッドを利用するようにしましょう。

// app/javascript/components/EventForm.js
// (省略)
const handleInputChange = (e) => {
  const { target } = e;
  const { name } = target;
  const value = target.type === 'checkbox' ? target.checked : target.value;

  updateEvent(name, value);
};
// (省略)

以上でdatepickerが使えるようになりました。

Pikadayで新規イベントフォームにdatepickerを表示

🔗 Webpackコンソールの警告メッセージ

本チュートリアルをShakapackerで進めている場合、この時点で以下の警告メッセージがブラウザコンソールに表示されます(esbuildをお使いの場合は次にスキップしてください)。

WARNING in ./node_modules/pikaday/pikaday.js 15:23-40
Module not found: Error: Can't resolve 'moment' ...

これは、Pikadayがmomentをオプショナルの依存関係としているためです。Pikadayは、momentが利用可能な場合にmomentを必須にします。残念ながら、これによってwebpackで上のエラーが発生します。エラーの理由については、webpackにかなり長いissueがありますのでそちらをどうぞ。

参考: Warning: Critical dependencies. · Issue #196 · webpack/webpack

Pikadayのメンテナーはこのissueを問題視しておらず、警告を無視するようにアドバイスしています。このアドバイスに従う場合は、config/webpacker.ymlを以下のように変更してwebpackの全画面エラー表示をオフにしておくのがよいでしょう。

# config/webpacker.yml
client:
  # Should we show a full-screen overlay in the browser
  # when there are compiler errors or warnings?
  overlay: false

別の方法としては、node_modules/pikaday/pikaday.jsファイルでmomentを必要としている箇所をコメントアウトする形でエラーを除去することも可能です。

// node_modules/pikaday/pikaday.js
(function (root, factory)
{
    'use strict';

    var moment;
    if (typeof exports === 'object') {
        // CommonJS module
        // Load moment.js as an optional dependency
        // try { moment = require('moment'); } catch (e) {}
        module.exports = factory(moment);
    } else if (typeof define === 'function' && define.amd) {
        // AMD. Register as an anonymous module.
        define(function (req)
        {
            // Load moment.js as an optional dependency
            var id = 'moment';
            // try { moment = req(id); } catch (e) {}
            return factory(moment);
        });
    } else {
        root.Pikaday = factory(root.moment);
    }
}(this, function (moment)
// (省略)

🔗 Pikadayを使う理由

Pikadayを使う理由はあるのでしょうか?Shakapackerでは目障りな警告メッセージが表示されるうえに、Pikadayの開発はあまり活発ではなさそうに見えます。PikadayのGitHubリポジトリには多数のオープンissueがあり、メンテナンスされていないならプロジェクトにそう表示して欲しいというissueも目に付きます(#862#884など)。

Pikaday/Pikaday - GitHub

にもかかわらず、Pikadayのダウンロード回数は100万/月を超えています。現在のバージョンは安定していてproductionでも広く使われているので、本チュートリアルでのユースケースでは十分です。また、Reactアプリケーションにサードパーティのライブラリを追加する方法についても本チュートリアルで説明しておきたいと思ったからです。

コンポーネントベースのdatepickerを使いたいのであれば、React Date Pickerがおすすめです。セットアップも簡単ですし、開発も盛んなようです。あるいは、npmjs.comで他のソリューションを探す手もあるでしょう。

参考: 他のライブラリとのインテグレーション – React
参考: How can I use Pikaday with ReactJS? - Stack Overflow

🔗 イベントを保存する

現在のアプリは、有効なイベントを保存してもコンソールに出力されるだけで、他に何も起きません。そこで、実際にデータベースに保存するために、<EventForm>コンポーネントにコールバック関数を渡すことにします。

<Editor>コンポーネントを以下のように変更します。

// app/javascript/components/Editor.js
import { Routes, Route, useNavigate } from 'react-router-dom';
// (省略)

const Editor = () => {
  // (省略)
  const navigate = useNavigate();

  useEffect(() => {
    // (省略)
  }, []);

  const addEvent = async (newEvent) => {
    try {
      const response = await window.fetch('/api/events', {
        method: 'POST',
        body: JSON.stringify(newEvent),
        headers: {
          Accept: 'application/json',
          'Content-Type': 'application/json',
        },
      });
      if (!response.ok) throw Error(response.statusText);

      const savedEvent = await response.json();
      const newEvents = [...events, savedEvent];
      setEvents(newEvents);
      window.alert('Event Added!');
      navigate(`/events/${savedEvent.id}`);
    } catch (error) {
      console.error(error);
    }
  };

  return (
    <>
      // (省略)
        <Routes>
          <Route path="new" element={<EventForm onSave={addEvent} />} />
          <Route path=":id" element={<Event events={events} />} />
        </Routes>
      // (省略)
    </>
  );
};

上のコードではaddEventメソッドが定義されていることがわかります。これはnewEventオブジェクトを受け取り、そのデータを用いて新しいイベントを作成するリクエストをAPIに送信します。
リクエストが成功すると、新しく作成されたイベントが、ステート内に保持されているイベント配列に追加されて、それに応じてUIを更新します。ここではnavigate関数も使っています。useNavigateフックによって利用可能になったnavigate関数は、URLを新しく作成されたイベントのものに更新します。

APIリクエストが失敗した場合(ネットワークの問題、サーバーがエラーコードのレスポンスを返すなど)、エラーがブラウザコンソールに出力されます。

また、addEventメソッドがコールバックとしてEventFormコンポーネントに渡されている点にもご注目ください。これで、このメソッドを<EventForm>コンポーネント内で呼ぶだけで済みます。

// app/javascript/components/EventForm.js
import PropTypes from 'prop-types';
// (省略)

const EventForm = ({ onSave }) => {
  // (省略)

  const handleSubmit = (e) => {
    e.preventDefault();
    const errors = validateEvent(event);

    if (!isEmptyObject(errors)) {
      setFormErrors(errors);
    } else {
      onSave(event);
    }
  };

  return (
    // (省略)
  );
};

export default EventForm;

EventForm.propTypes = {
  onSave: PropTypes.func.isRequired,
};

これで、「Save」をクリックしてイベントをデータベースに保存すると、保存に成功したことを表す「Event Added!」アラートボックスが表示されます。なお、この先に進む前に、本チュートリアルのアプリがすべて問題なく動作していることを確認しておくことをおすすめします。

🔗 イベントを削除する

きっと皆さんも私と同じように、本チュートリアルを進めながら適当なイベントをたくさんこしらえたことでしょう。それでは削除ボタンを追加してイベントを消せるようにしましょう💥

イベントの追加と同様に、イベントの削除も、<Editor>コンポーネント内で宣言してから、プロパティとして<Event>コンポーネントに渡すことにしたいと思います。

最初は以下のメソッドを追加します。

// app/javascript/components/Editor.js
// (省略)
const Editor = () => {
  // (省略)

  const addEvent = async (newEvent) => {
    // (省略)
  };

  const deleteEvent = async (eventId) => {
    const sure = window.confirm('Are you sure?');

    if (sure) {
      try {
        const response = await window.fetch(`/api/events/${eventId}`, {
          method: 'DELETE',
        });

        if (!response.ok) throw Error(response.statusText);

        window.alert('Event Deleted!');
        navigate('/events');
        setEvents(events.filter(event => event.id !== eventId));
      } catch (error) {
        console.error(error);
      }
    }
  };

  return (
    // (省略)
  );
};

deleteEventメソッドでは、イベントを本当に削除してよいかを確認するダイアログを表示します。ユーザーがOKすればDELETEリクエストをAPIに送信し、削除成功のレスポンスが返ったら、イベント削除に成功したことをダイアログで通知してから/eventsにリダイレクトし、削除されたイベントをステートから削除します。
addEventのときと同様に、リクエストが失敗した場合はブラウザコンソールにログ出力します。

次に、<Event>コンポーネントにdeleteEventコールバックを渡します。

// app/javascript/components/Editor.js
// (省略)
<Routes>
  <Route path="new" element={<EventForm onSave={addEvent} />} />
  <Route path=":id" element={<Event events={events} onDelete={deleteEvent} />} />
</Routes>
// (省略)

これで、<Event>コンポーネントにイベント削除ボタンを作れるようになりました。新しいイベントの作成とは異なり、削除では<Link>コンポーネントを使いません(削除リンクはクローラでフォローするハイパーリンクではないからです)。しかしスタイルの一貫性を保つため、削除ボタンは先ほどのCSSでリンクと同じスタイルにしています。

// app/javascript/components/Event.js
// (省略)
const Event = ({ events, onDelete }) => {
  // (省略)

  return (
    <div className="eventContainer">
      <h2>
        {event.event_date}
        {' - '}
        {event.event_type}
        <button
          className="delete"
          type="button"
          onClick={() => onDelete(event.id)}
        >
          Delete
        </button>
      </h2>
       // (省略)
    </div>
  );
};

Event.propTypes = {
  events: PropTypes.arrayOf(
    PropTypes.shape({
      // (省略)
    })
  ).isRequired,
  onDelete: PropTypes.func.isRequired,
};

これで、イベントを削除できるようになりました。

🔗 Flashメッセージを追加する

ブラウザのアラートボックスでもユーザーに通知するには十分ですが、見栄えがあまりよくありません。代わりにReact-ToastifyライブラリでFlashメッセージ機能を追加しましょう。npmまたはyarnでインストールします。

# npmの場合
npm i react-toastify
# yarnの場合
yarn add react-toastify

この機能も、独自のヘルパーファイル(notifications.js)に保存することにします。

touch app/javascript/helpers/notifications.js

このファイルに以下を追加します。

// app/javascript/helpers/notifications.js
import { toast, Flip } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';

const defaults = {
  position: 'top-right',
  autoClose: 5000,
  hideProgressBar: true,
  closeOnClick: true,
  pauseOnHover: true,
  draggable: true,
  progress: undefined,
  transition: Flip,
};

export const success = (message, options = {}) => {
  toast.success(message, Object.assign(defaults, options));
};

export const info = (message, options = {}) => {
  toast.info(message, Object.assign(defaults, options));
};

export const warn = (message, options = {}) => {
  toast.warn(message, Object.assign(defaults, options));
};

export const error = (message, options = {}) => {
  toast.error(message, Object.assign(defaults, options));
};

これで、いくつかの常識的なデフォルト値を1箇所に集約できたので、flashメッセージを呼び出すときの定型文を削減できるようになりました。

React-Toastifyには高度な設定機能があるので、このライブラリで他にどんなことができるかを知りたい方はplaygroundで通知スタイルを実験して自分好みのスタイルを見つけられます。トランジションエフェクトも設定可能ですので、ぜひ試してみてください。

次に、このライブラリを<App>コンポーネントに追加します。

// app/javascript/components/App.js
import { ToastContainer } from 'react-toastify';
// (省略)

const App = () => (
  <>
    <Routes>
      <Route path="events/*" element={<Editor />} />
    </Routes>
    <ToastContainer />
  </>
);
// (省略)

ここでは、flashメッセージ(toastメッセージ)を表示するためにアプリにToastContainerを追加しています。

次に、<Editor>コンポーネントでアラートを置き換えます。

// app/javascript/components/Editor.js
+import { success } from '../helpers/notifications';
// (省略)
const addEvent = async (newEvent) => {
  try {
    // (省略)
-   window.alert('Event Added!);
+   success('Event Added!');
    // (省略)
  } catch (error) {
    console.error(error);
  }
};

const deleteEvent = async (eventId) => {
  const sure = window.confirm('Are you sure?');

  if (sure) {
    try {
      // (省略)
-   window.alert('Event Deleted!);
+   success('Event Deleted!');
      // (省略)
    } catch (error) {
      console.error(error);
    }
  }
};

ついでにエラー処理もヘルパーメソッドに移動しましょう。
app/javascript/helpers/helpers.jsを以下のように変更します。

// app/javascript/helpers/helpers.js
import { error } from './notifications';
// (省略)
export const handleAjaxError = (err) => {
  error('Something went wrong');
  console.error(err);
};

<Editor>コンポーネントに以下を追加します。

// app/javascript/components/Editor.js
import { handleAjaxError } from '../helpers/helpers';

Editor.jsの以下の行(3箇所)を

console.error(error);

以下に置き換えます。

handleAjaxError(error);

Editor.jsの以下の行は不要になったので、コンポーネントから削除できます。

- const [isError, setIsError] = useState(false);
- setIsError(true);
- {isError && <p>Something went wrong. Check the console.</p>}

これで、イベントの作成や削除で以下のようにスタイル付きのflashメッセージが表示されるはずです。

イベントマネージャ: flashメッセージ

原注

エラーメッセージ機能(エンドポイントで入力ミスをした場合など)をテストする場合、問題の発生を通知するflashメッセージが1個ではなく2個表示される点にご注意ください。その理由は、StrictModeによってコンポーネントを2回レンダリングするためです(開発中のみ)。これによってコードの問題をテストで検出して警告します。

🔗 イベントを更新する

最後に追加するCRUD機能は、イベントの更新機能です。コードの重複を避けるため、<EventForm>コンポーネントを再利用することにします。

<EventForm>コンポーネントをイベントのリストとして渡すと、URLからイベントIDを取得して正しいイベントをリストから検索し、フォームのフィールドに正しい値を事前入力します。

また、updateEvent関数を定義して、ユーザーが「Save」ボタンをクリックしたときにイベント作成か既存イベント更新かに応じてアクションを変更するようにします。

最初は<Event>コンポーネントを更新して「Edit」リンクを追加しましょう(これはURLを変更するので、通常のリンクで問題ありません)。

// app/javascript/components/Event.js
import { useParams, Link } from 'react-router-dom';
// (省略)
<h2>
  {event.event_date}
  {' - '}
  {event.event_type}
  <Link to={`/events/${event.id}/edit`}>Edit</Link>
  <button
    className="delete"
    type="button"
    onClick={() => onDelete(event.id)}
  >
    Delete
  </button>
</h2>
// (省略)

<Editor>コンポーネントにupdateEvent関数を追加しましょう。

// app/javascript/components/Editor.js
// (省略)
const updateEvent = async (updatedEvent) => {
  try {
    const response = await window.fetch(
      `/api/events/${updatedEvent.id}`,
      {
        method: 'PUT',
        body: JSON.stringify(updatedEvent),
        headers: {
          Accept: 'application/json',
          'Content-Type': 'application/json',
        },
      }
    );

    if (!response.ok) throw Error(response.statusText);

    const newEvents = events;
    const idx = newEvents.findIndex((event) => event.id === updatedEvent.id);
    newEvents[idx] = updatedEvent;
    setEvents(newEvents);

    success('Event Updated!');
    navigate(`/events/${updatedEvent.id}`);
  } catch (error) {
    handleAjaxError(error);
  }
};
// (省略)

皆さんも少しは慣れてきた頃でしょうか。ここでは更新後のイベントデータをPUTリクエストでAPIに送信します。

成功のレスポンス(つまりエラーなし)が返ってきたら、newEvents変数を作成してeventsの現在の値に設定します。次に、newEvents配列内の更新されたイベントのインデックスを決定し、古いイベントを新しいイベントに差し替えます。最後にステートのevent値を更新してから、成功メッセージを表示して新しいイベントのページに移動します。

ステートは直接改変しない方が望ましいので、ここではそのための変数をあえて追加で作成しています。

次に、イベント編集ページへのルーティングを宣言します。イベント編集ページでは<EventForm>コンポーネントをレンダリングします。

// app/javascript/components/Editor.js
// (省略)
<Routes>
  <Route
    path=":id"
    element={<Event events={events} onDelete={deleteEvent} />}
  />
  <Route
    path=":id/edit"
    element={<EventForm events={events} onSave={updateEvent} />}
  />
  <Route path="new" element={<EventForm onSave={addEvent} />} />
</Routes>
// (省略)

<EventForm>コンポーネントでは、イベントのリストを渡したらフォームのフィールドに常に正しい値が事前表示されるようにする必要があります。

<EventForm>コンポーネントから以下の行を削除します。

// app/javascript/components/EventForm.js
- const [event, setEvent] = useState({
-   event_type: '',
-   event_date: '',
-   title: '',
-   speaker: '',
-   host: '',
-   published: false,
- });

次に以下を追加します。

// app/javascript/components/EventForm.js
import { useParams } from 'react-router-dom';
// (省略)

const EventForm = ({ events, onSave }) => {
  const { id } = useParams();

  const defaults = {
    event_type: '',
    event_date: '',
    title: '',
    speaker: '',
    host: '',
    published: false,
  }

  const currEvent = id? events.find((e) => e.id === Number(id)) : {};
  const initialEventState = { ...defaults, ...currEvent }
  const [event, setEvent] = useState(initialEventState);
  // (省略)
}

ここでは、URLから得た現在のイベントのIDをReact RouterのuseParamsフックで取得しています。このIDは整数値か、undefinedになります(フォームで新規イベントを作成する場合)。続いて、イベントフィールドのデフォルト値を宣言します。

次に、id変数の値をチェックします。undefined(新規イベント作成)の場合は、currEventに空オブジェクトを設定します。それ以外の場合は、イベントの配列をフィルタして更新対象のイベントを検索し、currEventの値に設定します。

次に、defaultscurrEventをマージしてinitialEventStateという新しい変数を作成し、それからステートのeventプロパティを宣言してinitialEventStateの値で初期化します。

ここにはイベントのeffectがあり、いくつかの適切なデフォルト値または編集対象イベントの値で初期化されます。

この方法は少し込み入っているように見えるかもしれません(理想としては、<EventForm>コンポーネントに表示する必要のあるイベントだけを渡せればよかったのです)。しかし、React Routerがバージョン6にアップグレードされてからは、親コンポーネント内の:idプロパティを参照する方法が見当たらなくなってしまいました。方法について心当たりのある方は、ぜひ元記事のコメントでお知らせください。

次に、eventから得た正しい値でフォームが初期化されるようにする必要があります。また、コンポーネントのプロパティバリデーションを更新する必要もあります。

// app/javascript/components/EventForm.js
// (省略)
return (
  <div>
    <h2>New Event</h2>
    {renderErrors()}

    <form className="eventForm" onSubmit={handleSubmit}>
      <div>
        <label htmlFor="event_type">
          <strong>Type:</strong>
          <input
            type="text"
            id="event_type"
            name="event_type"
            onChange={handleInputChange}
            value={event.event_type}
          />
        </label>
      </div>
      <div>
        <label htmlFor="event_date">
          <strong>Date:</strong>
          <input
            type="text"
            id="event_date"
            name="event_date"
            ref={dateInput}
            autoComplete="off"
            value={event.event_date}
            onChange={handleInputChange}
          />
        </label>
      </div>
      <div>
        <label htmlFor="title">
          <strong>Title:</strong>
          <textarea
            cols="30"
            rows="10"
            id="title"
            name="title"
            onChange={handleInputChange}
            value={event.title}
          />
        </label>
      </div>
      <div>
        <label htmlFor="speaker">
          <strong>Speakers:</strong>
          <input
            type="text"
            id="speaker"
            name="speaker"
            onChange={handleInputChange}
            value={event.speaker}
          />
        </label>
      </div>
      <div>
        <label htmlFor="host">
          <strong>Hosts:</strong>
          <input
            type="text"
            id="host"
            name="host"
            onChange={handleInputChange}
            value={event.host}
          />
        </label>
      </div>
      <div>
        <label htmlFor="published">
          <strong>Publish:</strong>
          <input
            type="checkbox"
            id="published"
            name="published"
            onChange={handleInputChange}
            checked={event.published}
          />
        </label>
      </div>
      <div className="form-actions">
        <button type="submit">Save</button>
      </div>
    </form>
  </div>
);

// (省略)

EventForm.propTypes = {
  events: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.number.isRequired,
      event_type: PropTypes.string.isRequired,
      event_date: PropTypes.string.isRequired,
      title: PropTypes.string.isRequired,
      speaker: PropTypes.string.isRequired,
      host: PropTypes.string.isRequired,
      published: PropTypes.bool.isRequired,
    })
  ),
  onSave: PropTypes.func.isRequired,
};

EventForm.defaultProps = {
  events: [],
};

また、datepickerの初期設定でtoString関数を渡し、フォームにイベント日付が追加されたときに正しくフォーマットされるようにしておく必要もあります。

// app/javascript/components/EventForm.js
// (省略)
useEffect(() => {
  const p = new Pikaday({
    field: dateInput.current,
+   toString: date => formatDate(date),
    onSelect: (date) => {
      // (省略)
    },
  });

  // クリーンアップ用の関数を返す
  // Reactはアンマウントの前にこれを呼び出す
  return () => p.destroy();
}, []);

最後に、useEffectフックをもう1つ追加して、ユーザーがイベントを編集中に「New Event」をクリックしたらフィールドがクリアされるようにする必要があります(クラスベースのコンポーネントを使うとしたら、componentWillReceivePropsというライフサイクルメソッドを使えばできたでしょう)。

// app/javascript/components/EventForm.js
// (省略)
useEffect(() => {
  setEvent(initialEventState);
}, [events]);
// (省略)

依存関係の配列の変更を監視するためのプロパティを指定している点にご注意ください。

以上で、CRUD機能がすべてできあがりました。アプリを開いて手動で動作を確認し、すべて問題なく動作していることを確認してから次に進んでください。

🔗 ESLintの警告に対応する

ESLintをインストールしてある場合は、アプリのコードでESLintの警告メッセージが表示されることに気づくでしょう。

エラー「React Hook useEffect has a missing dependency: 'initialEventState'. Either include it or remove the dependency array」

React Hook useEffect has a missing dependency: 'initialEventState'. Either include it or remove the dependency array

このコードで何が起きているのでしょうか?

この警告は、2つ目のuseEffectフックが使っている値がレンダリング中に変更される可能性があることを指摘しています。ここでは、initialEventStateが以下のように定義されている点が警告されています。

const defaults = { ... };
const currEvent = id? events.find((e) => e.id === Number(id)) : {};
const initialEventState = { ...defaults, ...currEvent }

犯人はcurrEventです。この値はideventsの両方に依存しています。

以下のようにコメントを追加すれば一応警告をオフにできます。

// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
  setEvent(initialEventState);
}, [events]);

しかしReactチームはこの警告をオフにすることを推奨していません

警告メッセージのアドバイスに従って、initialStateを依存配列の内側に移動すると以下のようになります。

useEffect(() => {
  setEvent(initialEventState);
}, [events, initialEventState]);

しかし今度は別の警告が表示されます。

The 'initialEventState' object makes the dependencies of useEffect Hook (at line 58) change on every render.
To fix this, wrap the initialization of 'initialEventState' in its own useMemo() Hook.

しかもこの方法だと、フォームで1文字キー入力するたびに初期ステートが元に戻ってしまうという厄介な副作用があるため、フォームが使い物になりません。

これを修正するには、以下のようにuseCallbackフックを使えます。

EventFormコンポーネントから以下のコードを削除します。

// app/javascript/components/EventForm.js
- const defaults = {
-   event_type: '',
-   event_date: '',
-   title: '',
-   speaker: '',
-   host: '',
-   published: false,
- }
- const currEvent = id? events.find((e) => e.id === Number(id)) : {};
- const initialEventState = { ...defaults, ...currEvent }

上で削除した部分を以下に置き換えます。

// app/javascript/components/EventForm.js
// (省略)
const initialEventState = useCallback(
  () => {
    const defaults = {
      event_type: '',
      event_date: '',
      title: '',
      speaker: '',
      host: '',
      published: false,
    };

    const currEvent = id ?
      events.find((e) => e.id === Number(id)) :
      {};

    return { ...defaults, ...currEvent }
  },
  [events, id]
);
// (省略)

EventFormコンポーネントの冒頭でuseCallbackをインポートする必要もあります。

// app/javascript/components/EventForm.js
import React, { useState, useRef, useEffect, useCallback } from 'react';

このuseCallbackフックが返すのはメモ化バージョンの関数で、その依存関係(eventsおよびid)も変更されない限りレンダリングを変更しません。これこそ欲しい機能です。

詳しくはReactの以下のドキュメントをどうぞ。

参考: フックに関するよくある質問 – React

フォームにさらに手を加える

次は、フォームに「Cancel」ボタンを追加して、ユーザーがイベントの編集中や作成中に作業を中断できるようにしましょう。また、ユーザーが実行しているアクションがわかるように、フォームのタイトルにアクションを反映させます。その他に、日付フィールドのバリデーションを改善します(現在は値が入力されているかどうかをチェックするだけです)。

<EventForm>コンポーネントを以下のように変更します。

// app/javascript/components/EventForm.js
// (省略)
import { useParams, Link } from 'react-router-dom';

const EventForm = ({ events, onSave }) => {
  // (省略)
  const cancelURL = event.id ? `/events/${event.id}` : '/events';
  const title = event.id ? `${event.event_date} - ${event.event_type}` : 'New Event';

  return (
    <div>
      <h2>{title}</h2>
      {renderErrors()}
      <form className="eventForm" onSubmit={handleSubmit}>
        // (省略)
        <div className="form-actions">
          <button type="submit">Save</button>
          <Link to={cancelURL}>Cancel</Link>
        </div>
      </form>
    </div>
  );
};
// (省略)

次にhelpers.jsに日付バリデーションを追加します。

// app/javascript/helpers/helpers.js
// (省略)
const isValidDate = dateObj => !Number.isNaN(Date.parse(dateObj));

export const validateEvent = (event) => {
  // (省略)

  if (!isValidDate(event.event_date)) {
    errors.event_date = 'You must enter a valid date';
  }

 // (省略)

  return errors;
};
 // (省略)

最後に、ヘッダーのアプリ名にリンクを追加して、クリックしたらメインのビューに戻れるようにしましょう。<Header>コンポーネントを以下のように変更します。

// app/javascript/components/Header.js
// (省略)
import { Link } from 'react-router-dom';

const Header = () => (
  <header>
    <Link to='/events/'>
      <h1>Event Manager</h1>
    </Link>
  </header>
);
// (省略)

🔗 絞り込み機能を追加

イベントリストに絞り込み機能を追加できると便利です。ありがたいことに、ステート内にすべてのイベントが保持されているので難しくありません。

最初は、<EventList>コンポーネントに検索フィールドを追加します。

// app/javascript/components/EventList.js
// (省略)
const EventList = ({ events }) => {
  // (省略)
  return (
    <section className="eventList">
      <h2>
        Events
        <Link to="/events/new">New Event</Link>
      </h2>

      <input
        className="search"
        placeholder="Search"
        type="text"
        ref={searchInput}
        onKeyUp={updateSearchTerm}
      />

      <ul>{renderEvents(events)}</ul>
    </section>
  );
};
// (省略)

ここではinput要素にrefを追加してコンポーネント内の他の場所で参照できるようにしてある点にご注意ください。
それでは、このrefを作成してステートでsearchTermプロパティを宣言しましょう。

// app/javascript/components/EventList.js
import React, { useState, useRef } from 'react';
// (省略)

const EventList = ({ events }) => {
  const [searchTerm, setSearchTerm] = useState('');
  const searchInput = useRef(null);

  const updateSearchTerm = () => {
    setSearchTerm(searchInput.current.value);
  };
  // (省略)
};
// (省略)

ここではupdateSearchTermメソッドも作成しています。このメソッドは、検索フィールドでキー入力が登録されるたびに呼び出されます。

イベントリストはrenderEventsメソッド内でレンダリングされます。このイベントリストにフィルタを適用して、検索条件に一致するイベントだけを表示するようにしてみましょう。
renderEvents関数全体を以下で置き換えます。

// app/javascript/components/EventList.js
const renderEvents = (eventArray) =>
  eventArray
    .filter((el) => matchSearchTerm(el))
    .sort((a, b) => new Date(b.event_date) - new Date(a.event_date))
    .map((event) => (
      <li key={event.id}>
        <NavLink to={`/events/${event.id}`}>
          {event.event_date}
          {' - '}
          {event.event_type}
        </NavLink>
      </li>
    ));

最後に、matchSearchTermメソッドが必要です。

// app/javascript/components/EventList.js
// (省略)
const EventList = ({ events }) => {
  // (省略)

  const matchSearchTerm = (obj) => {
    const { id, published, created_at, updated_at, ...rest } = obj;
    return Object.values(rest).some(
      (value) => value.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1
    );
  };

  const renderEvents = (eventArray) => eventArray
    // (省略)
  });

  return (
     // (省略)
  );
};
// (省略)

ここでは、オリジナルのAjax呼び出しによって返されたデータベースフィールドのうち、フィルタリングしたものを除外しています。続いて、関連するフィールドのいずれかに検索語を含むイベントのみを返します。

ご覧ください、初歩的な絞り込み機能が使えるようになりました。

イベントマネージャ: 絞り込み機能

🔗 404のコンポーネントを追加する

本チュートリアルの最後は、イベントが見つからないときにレンダリングするコンポーネントを追加します。これは、ユーザーがブックマークしたイベントが削除されている場合に適切な表示です。

最初に、<EventNotFound>コンポーネントを作成します。

touch app/javascript/components/EventNotFound.js

<EventNotFound>コンポーネントに以下を追加します。特に変わったことは行っていません。

// app/javascript/components/EventNotFound.js
import React from 'react';

const EventNotFound = () => <p>Event not found!</p>;

export default EventNotFound;

次は、<Event>コンポーネントの更新が必要です。

// app/javascript/components/Event.js
// (省略)
import EventNotFound from './EventNotFound';

const Event = ({ events, onDelete }) => {
  // (省略)

  if (!event) return <EventNotFound />;

  return (
    // (省略)
  );
};
// (省略)

最後に、<EventForm>コンポーネントを更新します。

// app/javascript/components/EventForm.js
// (省略)
import EventNotFound from './EventNotFound';

const EventForm = ({ events, onSave }) => {
  // (省略)

  if (id && !event.id) return <EventNotFound />;

  return (
    // (省略)
  );
};
// (省略)

これで、存在しないイベントを参照しようとすると、404コンポーネントが生成されます。

🔗 最後に

以上でチュートリアルはすべておしまいです。ここまで終えた方はお疲れさまでした。これでReactとRailsによるCRUDアプリが完全に動くようになり、このようなアプリを構築するうえで必要となる妥当な概要をすべて身に付けられたはずです。

皆さんが自分の手で同様のSPAを構築するときに、本チュートリアルがよい出発点となることを願っています。

さらにスキルを高めたい方は、以下のようなステップも検討するとよいでしょう。

皆さんのご意見やご感想がありましたら、原文末尾のコメント欄までお寄せいただければ幸いです。冒頭でも述べたように、本チュートリアルの完全なソースコードは以下のGitHubリポジトリでご覧いただけます。

jameshibbard/react-rails-crud-app - GitHub

関連記事

Railsでブラウザテストを「正しく」行う方法(翻訳)

Rails: Webpacker(Shakapacker)とjsbundling-railsの比較(翻訳)

Rails: Webpacker v5からShakapacker v6へのアップグレードガイド(翻訳)


  1. 訳注: 本記事ではPostmanそのもののセットアップについては省略されています。記載の手順を実行するには、Postmanにアカウントを登録したうえで、Postmanのエージェント(3種類)のうち1つを選んで設定する必要があります。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。