Rails 7とReactによるCRUDアプリ作成チュートリアル(翻訳)
ほとんどのWebアプリケーションでは、何らかの形式でデータを永続化する必要があります。これは、サーバーサイド言語で作業する場合はシンプルにやれるのが普通です。しかし、そこにフロントエンドのJavaScriptフレームワークも加わってくると、少しややこしくなり始めます。
本チュートリアルでは、Ruby on RailsでJSON APIを構築して、そのAPIと通信する完全なReactフロントエンドをコーディングする方法を紹介します。ここで作成するアプリは、学術イベントのリストを作成・管理する「イベントマネージャ」です。
このアプリでは、基本的なCRUD機能に、datepicker(日付選択ボックス)や絞り込みなどの機能をいくつか追加します。
最終的なアプリは以下のような外観になります。
本チュートリアルの完全なコードはGitHubで参照できます。
🔗 前提条件
本チュートリアルを進めるには、自分のシステムにRubyとNodeがインストールされている必要があります。Rubyについては、公式サイトから自分のシステムに合う公式バイナリをダウンロードするか、rbenvなどのバージョン管理ソフトを使ってインストールします。
Nodeについても同様に、公式サイトから自分のシステムに合う公式バイナリをダウンロードするか、nvmなどのバージョン管理ソフトを使ってインストールします。
筆者は、RubyとNodeのどちらについてもバージョン管理ソフトを使うことをおすすめします。セットアップも簡単ですし、複数バージョンのRubyやNodeを管理しやすくなります。また、RubyやNodeのインストールで管理権限が不要になるので、パーミッションの問題を解決するうえでも有用です。
本チュートリアルでは、Ruby 3.1とNode 16(最新のLTS)を使います。私のOS環境はLinux Mintなので、ターミナルのコマンドは*nix向けに統一します。
🔗 利用する技術スタック
このようなアプリを構築する方法は1種類ではなく、さまざまな方法があります。本セクションでは、筆者が用いたライブラリの概要と、選択した技術について解説します。
筆者は以下のライブラリを使っています。
- Rails -- バージョン7
- React -- バージョン18
- React Router バージョン 6
- Pikaday
- React-Toastify
- React Prop Types
- ESLint
データベースは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アプリを別プロジェクトにすることも可能ですが、この規模のアプリなら、どちらかのプロジェクトを他方のプロジェクトに相乗りさせる方が好みです。
🔗 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が空セッションで応答します。これにより、他のスクリプトが認証済みセッションを悪用するのを防止できます。これについて詳しくは、以下の記事をどうぞ。
- Understanding Rails' Forgery Protection Strategies
- A Deep Dive into CSRF Protection in Rails
- Rails CSRF protection for SPA
- Configuring Rails as a JSON API
🔗 ルーティング
最後に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の代わりに、以下のように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>
コンポーネント: イベントの作成と編集を行う
全体像は以下のようになります。
🔗 イベントをフェッチする
最初に、本セクションで必要なファイルを作成しましょう。
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のコア部分を構成するTurboとStimulusに関連します。これらはReactアプリケーションには関係ありませんが、消さずに残しておくことをおすすめします。
上のコードでは、<StrictMode>
コンポーネント内に<App>
コンポーネントがラップされていることもわかります。これは何のUIも表示しませんが、developmentモードでその子孫コンポーネントのチェックと警告を有効にする追加のヘルパーコンポーネントです。詳しくは以下をご覧ください。
🔗 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つの変数(events
、isLoading
、isError
)と、それらの変数に値を設定する関数を宣言します。変数には初期値もいくつか代入しておきます。
次は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
に設定します。
原注
この読み込みの効果を実際に確かめたいときは、EventsController
のindex
メソッドに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による以下のチュートリアルをおすすめします。
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
は、配列を日付の降順でソートしてから、各イベントのリスト項目をレンダリングします。
ここでご注意いただきたいのは、いくつかのプロパティバリデーションも実装してevents
propがオブジェクトの配列になるようにしていることと、その配列内のオブジェクトが特定のプロパティセットを持つようにしていることです。ここでは、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コードを含められるようにする指定、console
やalert
で警告を出さないようにする指定、そして関数コンポーネントでアロー関数(=>
)構文を利用できるようにする設定も行います。
関数コンポーネントで別の種類の関数を強制したい場合は、以下の設定方法をどうぞ。
参考: 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とSublimeLinter, SublimeLinter-eslint、SublimeJsPrettierで良好な結果を得ています。その他に、Prettierとコンフリクトする可能性のあるlintルールをオフにできるeslint-config-prettierも使っています。
VSCodeをお使いの方なら、Wes Bosによる以下の動画をどうぞ。
🔗 ReactのDeveloper Tools
しばらくツールの話題が続きましたが、あとReactのDeveloper Toolsについても少しだけお時間をいただきたいと思います。ReactのDeveloper Toolsを使うと、Reactのコンポーネント階層(コンポーネントのプロパティやステートなど)をブラウザ拡張で調べられるようになります。
本チュートリアルの手順に忠実に沿って進めていれば、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による以下のチュートリアルをおすすめします。
手始めに、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"とログ出力されるはずです。
🔗 フォームのバリデーション
それでは、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つの変数event
とformErrors
を定義します。
event
変数はオブジェクトとしていくつかの妥当なデフォルト値で初期化され、formErrors
は空オブジェクトとして初期化されます。
次はhandleInputChange
関数です。このフォーム内のすべてのフィールドは「制御された入力」にする(つまりフィールドのステートの維持や設定はReactが行う)ことにします。
ユーザーがどのフィールドの値を変更しても、そのたびにhandleInputChange
関数が呼び出されてevent
オブジェクトが更新され、フォームへの入力がevent
オブジェクトに反映されます。name
などの変数をオブジェクトのキーとして扱うには、[name]
のように角かっこで囲む点にご注意ください。
次はヘルパー関数validateEvent
とisEmptyObject
です。
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
別の方法として、依存配列useEffect
にevent
を追加することでも一応解決できます。ただしこの方法だと、ユーザーがフォームに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が使えるようになりました。
🔗 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のダウンロード回数は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メッセージが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
の値に設定します。
次に、defaults
とcurrEvent
をマージして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
このコードで何が起きているのでしょうか?
この警告は、2つ目のuseEffect
フックが使っている値がレンダリング中に変更される可能性があることを指摘しています。ここでは、initialEventState
が以下のように定義されている点が警告されています。
const defaults = { ... };
const currEvent = id? events.find((e) => e.id === Number(id)) : {};
const initialEventState = { ...defaults, ...currEvent }
犯人はcurrEvent
です。この値はid
とevents
の両方に依存しています。
以下のようにコメントを追加すれば一応警告をオフにできます。
// 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の以下のドキュメントをどうぞ。
フォームにさらに手を加える
次は、フォームに「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を構築するときに、本チュートリアルがよい出発点となることを願っています。
さらにスキルを高めたい方は、以下のようなステップも検討するとよいでしょう。
- アプリをHerokuにデプロイする
- 認証を追加する
- バックエンドをNodeに移植する
皆さんのご意見やご感想がありましたら、原文末尾のコメント欄までお寄せいただければ幸いです。冒頭でも述べたように、本チュートリアルの完全なソースコードは以下のGitHubリポジトリでご覧いただけます。
関連記事
- 訳注: 本記事ではPostmanそのもののセットアップについては省略されています。記載の手順を実行するには、Postmanにアカウントを登録したうえで、Postmanのエージェント(3種類)のうち1つを選んで設定する必要があります。 ↩
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。