対象読者
- バックエンドとフロントエンドを切り離してアプリを作成したい人(今回はRailsとReact)
長い事フロントエンドから逃げ続けてきた自分ですが、さすがにそろそろ何かしら身に付けないとマズいと思いReactに入門した際のメモ書きです。
どちらかと言うと「バックエンドとフロントエンドを切り離した環境構築」に重きを置いた書き方になるため、コード自体の解説はほとんど無いに等しいかもしれません。
Railsはもちろん、ReactやTypeScriptに関してもある程度は事前に学習した上で読んだ方が良いと思います。
Dockerfileを書き始めるところから始めるので、ちゃんと手順に沿っていれば同じものは作れます。とりあえず手を動かして雰囲気を掴みたい人向けです。
完成イメージ
仕様
- Ruby3
- Rails6(APIモード)
- React
- TypeScript
- MySQL8.0
rails-react-todo
├─ backend
├─ app
├─ bin
├─ config
├─ db
├─ lib
├─ log
├─ public
├─ storage
├─ test
├─ tmp
├─ vendor
├─ その他ファイル
├─ frontend
├─ react-app
├─ node_modules
├─ public
├─ src
├─ components
├─ TodoForm.tsx
├─ TodoItem.tsx
├─ TodoList.tsx
├─ Types.d.tsx
├─ App.tsx
├─ index.tsx
├─ その他ファイル
├─ その他ファイル
├─ docker-compose.yml
良くあるパターンとして、Rails主体で作ったプロジェクトに「react-rails」などのgemを使って部分的にReactを取り入れるといったものがあると思いますが、今回はバックエンド(Rails)とフロントエンド(React)で分断しました。
環境構築
まず最初に環境構築のため各種ディレクトリ・ファイルを作成していきます。
プロジェクト本体
$ mkdir rails-react-todo && cd rails-react-todo
この辺は任意の名前で適当に。
$ touch docker-compose.yml
version: "3"
services:
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: password
command: --default-authentication-plugin=mysql_native_password
volumes:
- mysql-data:/var/lib/mysql
- /tmp/dockerdir:/etc/mysql/conf.d/
ports:
- 3306:3306
api:
build:
context: ./backend/
dockerfile: Dockerfile
command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
volumes:
- ./backend:/myapp
- ./backend/vendor/bundle:/myapp/vendor/bundle
environment:
TZ: Asia/Tokyo
RAILS_ENV: development
ports:
- "3001:3000"
depends_on:
- db
front:
build:
context: ./frontend/
dockerfile: Dockerfile
volumes:
- ./frontend:/usr/src/app
command: sh -c "cd react-app && yarn start"
ports:
- "3000:3000"
volumes:
mysql-data:
バックエンド
$ mkdir backend
$ touch backend/Dockerfile
$ touch backend/entrypoint.sh
$ touch backend/Gemfile
$ touch backend/Gemfile.lock
FROM ruby:3.0
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs
ENV APP_PATH /myapp
RUN mkdir $APP_PATH
WORKDIR $APP_PATH
COPY Gemfile $APP_PATH/Gemfile
COPY Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install
COPY . $APP_PATH
COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]
#!/bin/bash
set -e
# Remove a potentially pre-existing server.pid for Rails.
rm -f /myapp/tmp/pids/server.pid
# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
# frozen_string_literal: true
source "https://rubygems.org"
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
gem "rails", "~> 6"
# 空欄でOK
フロントエンド
$ mkdir frontend
$ touch frontend/Dockerfile
FROM node:14.4.0-alpine3.10
WORKDIR /usr/src/app
Railsアプリ(APIモード)を作成
準備ができたので先にRailsアプリから作成していきます。
rails new
$ docker-compose run api rails new . --force --no-deps -d mysql --api
前述の通りAPIモードで作成。
database.ymlを編集
デフォルトの状態だとデータベースとの接続ができないため、「./backend/config/database.yml」の一部を書き換えます。
default: &default
adapter: mysql2
encoding: utf8mb4
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: root
password: password # デフォルトだと空欄になっているはずなので変更
host: db # デフォルトだとlocalhostになっているはずなので変更
development:
<<: *default
database: myapp_development
test:
<<: *default
database: myapp_test
production:
<<: *default
database: <%= ENV["DATABASE_NAME"] %>
username: <%= ENV["DATABASE_USERNAME"] %>
password: <%= ENV["DATABASE_PASSWORD"] %>
Reactアプリを作成
次にReactアプリを作成していきます。
create-react-app
$ docker-compose build
$ docker-compose run front yarn create react-app react-app --template typescript
コンテナを起動
正常に動くかどうか確かめるためにコンテナを起動します。
$ docker-compose up -d
$ docker-compose run api bundle exec rake db:create
localhost:3001
localhost:3000
それぞれ「localhost:3001」と「localhost:3000」にアクセスしておなじみの画面が表示されれば成功です。
実装
下準備ができたので、本格的な実装に入ります。
バックエンド
モデルを作成
$ docker-compose run api rails g model Todo title:string
$ docker-compose run api rails db:migrate
class Todo < ApplicationRecord
validates :title, presence: true, length: { maximum: 140 }
end
- title必須
- 最大140文字
忘れずにバリデーションも設定。
コントローラーを作成
$ docker-compose run api rails g controller api/v1/todos
class Api::V1::TodosController < ApplicationController
def index
render json: { status: 200, todos: Todo.all }
end
def create
todo = Todo.new(todo_params)
if todo.save
render json: { status: 200, todo: todo }
else
render json: { status: 500, message: "Todoの作成に失敗しました" }
end
end
def destroy
todo = Todo.find(params[:id])
if todo.destroy
render json: { status: 200, todo: todo }
else
render json: { status: 500, message: "Todoの削除に失敗しました" }
end
end
private
def todo_params
params.require(:todo).permit(:title)
end
end
ルーティングを記述
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :todos, only: [:index, :create, :destroy]
end
end
end
初期データを作成
puts 'Creating todos...'
# 適当なTodoを5つ作成
5.times do |i|
Todo.create(title: "Todo#{i + 1}")
end
puts '...Finished!'
動作確認用の初期データを作成。
$ docker-compose run api rails db:seed
curlコマンドで確認
$ curl -X GET http://localhost:3001/api/v1/todos
{
"status": 200,
"todos": [
{
"id": 1,
"title": "Todo1",
"created_at": "2021-06-26T09:15:32.861Z",
"updated_at": "2021-06-26T09:15:32.861Z"
},
{
"id": 2,
"title": "Todo2",
"created_at": "2021-06-26T09:15:32.870Z",
"updated_at": "2021-06-26T09:15:32.870Z"
},
{
"id": 3,
"title": "Todo3",
"created_at": "2021-06-26T09:15:32.877Z",
"updated_at": "2021-06-26T09:15:32.877Z"
},
{
"id": 4,
"title": "Todo4",
"created_at": "2021-06-26T09:15:32.884Z",
"updated_at": "2021-06-26T09:15:32.884Z"
},
{
"id": 5,
"title": "Todo5",
"created_at": "2021-06-26T09:15:32.892Z",
"updated_at": "2021-06-26T09:15:32.892Z"
}
]
}
しっかりデータが挿入されているのが確認できれば成功です。
CORS設定
今回の構成ではバックエンドとフロントエンドを完全に分けているため、RailsとReactがそれぞれ別のポート番号で立ち上がっています。(localhost:3001とlocalhost:3000)
この場合、デフォルトの状態だとセキュリティの問題でReact側からRails側のAPIを使用できない点に注意が必要です。
使用できるようにするためには、「CORS(クロス・オリジン・リソース・シェアリング)」の設定を行わなければなりません。
rack-corsをインストール
gem 'rack-cors'
CORSの設定を簡単に行えるgemがあるのでインストールしましょう。APIモードで作成している場合、すでにGemfile内に記載されているのでそちらのコメントアウトを外せばOKです。
$ docker-compose build
Gemfileを更新したので再度ビルド。
cors.rbを編集
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'localhost:3000'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
「config/initializers/」に設定ファイルが存在するはずなので、「localhost:3000」からアクセス可能なように編集しておきます。
$ docker-compose restart
設定の変更を反映させるためにコンテナを再起動。
フロントを作成
先ほど作ったAPIを利用するため、フロントエンド部分の作り込みを行っていきます。
不要なファイルを削除
これから作業を行っていく上で、自動生成された各種ファイルがどうしても邪魔に感じるので削除してしまいます。
$ rm frontend/react-app/src/App.css frontend/react-app/src/App.test.tsx frontend/react-app/src/logo.svg frontend/react-app/src/reportWebVitals.ts frontend/react-app/src/setupTests.ts
- App.css
- App.test.tsx
- index.css
- logo.svg
今回は全て使う事の無いファイルです。
既存のファイルを編集
また、いくつかファイルを削除した影響で不具合が生じるようになってしまうため、既存のファイルを書き換えます。
import React from "react"
const App: React.FC = () => {
return (
<h1>Hello React!</h1>
)
}
export default App
import React from "react"
import ReactDOM from "react-dom"
import "./index.css"
import App from "./App"
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
)
トップページが「Hello React!」に変わっていればOKです。
各種ライブラリをインストール
$ docker-compose run front yarn add axios
$ docker-compose run front yarn add -D @types/axios
今回は axios というHTTPクライアントを使ってRails側と通信を行うので、あらかじめインストールしておきましょう。
各種ディレクトリ&ファイルを作成
$ mkdir frontend/react-app/src/interfaces
$ touch frontend/react-app/src/interfaces/index.ts
$ mkdir frontend/react-app/src/lib
$ mkdir frontend/react-app/src/lib/api
$ touch frontend/react-app/src/lib/api/client.ts
$ touch frontend/react-app/src/lib/api/todos.ts
$ mkdir frontend/react-app/src/components
$ touch frontend/react-app/src/components/TodoForm.tsx
$ touch frontend/react-app/src/components/TodoList.tsx
$ touch frontend/react-app/src/components/TodoItem.tsx
- interfaces
- index.ts: 型定義
- lib
- api: apiを叩くための関数をまとめた場所
- components
- TodoForm.tsx: 入力フォーム
- TodoList.tsx: Todoリスト
- TodoItem.tsx: Todo本体
export interface Todo {
id?: number
title: string
}
import axios from "axios"
const client = axios.create({
baseURL: "http://localhost:3001/api/v1"
})
export default client
import client from "./client"
import { Todo } from "../../interfaces/index"
// todo一覧を取得
export const getTodos = () => {
return client.get("/todos")
}
// todoを新規作成
export const createTodo = (data: Todo) => {
return client.post("/todos", data)
}
// todoを削除
export const deleteTodo = (id: number) => {
return client.delete(`/todos/${id}`)
}
import React, { useState } from "react"
import { createTodo } from "../lib/api/todos"
import { Todo } from "../interfaces/index"
interface TodoFormProps {
todos: Todo[]
setTodos: Function
}
export const TodoForm: React.FC<TodoFormProps> = ({ todos, setTodos }) => {
const [title, setTitle] = useState<string>("")
const handleCreateTodo = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const data: Todo = {
title: title
}
try {
const res = await createTodo(data)
console.log(res)
if (res.status == 200) {
setTodos([...todos, res.data.todo])
} else {
console.log(res.data.message)
}
} catch (err) {
console.log(err)
}
setTitle("")
}
return (
<form onSubmit={handleCreateTodo}>
<input
type="text"
value={title}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value)
}}
/>
<input type="submit" value="Add" disabled={!title} />
</form>
)
}
import React from "react"
import { TodoItem } from "./TodoItem"
import { Todo } from "../interfaces/index"
interface TodoListProps {
todos: Todo[]
setTodos: Function
}
export const TodoList: React.FC<TodoListProps> = ({ todos, setTodos }) => {
return (
<table>
<thead>
<tr>
<th>Todos</th>
<th></th>
</tr>
</thead>
<tbody>
{
todos.map((todo: Todo, index: number) => {
return (
<TodoItem
key={index}
todo={todo}
setTodos={setTodos}
/>
)
})
}
</tbody>
</table>
)
}
import React from "react"
import { deleteTodo } from "../lib/api/todos"
import { Todo } from "../interfaces/index"
interface TodoItemProps {
todo: Todo
setTodos: Function
}
export const TodoItem: React.FC<TodoItemProps> = ({ todo, setTodos }) => {
const handleDeleteTodo = async (id: number) => {
try {
const res = await deleteTodo(id)
console.log(res)
if (res?.status === 200) {
setTodos((prev: Todo[]) => prev.filter((todo: Todo) => todo.id !== id))
} else {
console.log(res.data.message)
}
} catch (err) {
console.log(err)
}
}
return (
<tr>
<td>{todo.title}</td>
<td>
<button onClick={() => handleDeleteTodo(todo.id || 0)}>Delete</button>
</td>
</tr>
)
}
App.tsxを編集
各コンポーネントを呼び出せるように「./frontend/react-app/src/App.tsx」を編集します。
import React, { useState, useEffect } from "react"
import { TodoList } from "./components/TodoList"
import { TodoForm } from "./components/TodoForm"
import { getTodos } from "./lib/api/todos"
import { Todo} from "./interfaces/index"
const App: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([])
const handleGetTodos = async () => {
try {
const res = await getTodos()
console.log(res)
if (res?.status === 200) {
setTodos(res.data.todos)
} else {
console.log(res.data.message)
}
} catch (err) {
console.log(err)
}
}
useEffect(() => {
handleGetTodos()
}, [])
return (
<>
<h1>Todo App</h1>
<TodoForm todos={todos} setTodos={setTodos} />
<TodoList todos={todos} setTodos={setTodos} />
</>
)
}
export default App
完成
最終的にこんな感じになっていれば完成です。
あとがき
今回作成したコードのリポジトリを記載しておくので、もし手順通りに進めて動かない部分などがありましたらそちらと比較して間違っている部分などは無いか確認してみてください。