59
Help us understand the problem. What are the problem?

posted at

updated at

Rails API × React × TypeScriptで作るシンプルなTodoアプリ

対象読者

  • バックエンドとフロントエンドを切り離してアプリを作成したい人(今回はRailsとReact)

長い事フロントエンドから逃げ続けてきた自分ですが、さすがにそろそろ何かしら身に付けないとマズいと思いReactに入門した際のメモ書きです。

どちらかと言うと「バックエンドとフロントエンドを切り離した環境構築」に重きを置いた書き方になるため、コード自体の解説はほとんど無いに等しいかもしれません。

Railsはもちろん、ReactやTypeScriptに関してもある程度は事前に学習した上で読んだ方が良いと思います。

Dockerfileを書き始めるところから始めるので、ちゃんと手順に沿っていれば同じものは作れます。とりあえず手を動かして雰囲気を掴みたい人向けです。

完成イメージ

todo.gif

仕様

  • 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
./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
./backend/Dockefile
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"]
./backend/entrypoint.sh
#!/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 "$@"
./backend/Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails", "~> 6"
./backend/Gemfile.lock
# 空欄でOK

フロントエンド

$ mkdir frontend
$ touch frontend/Dockerfile
./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」の一部を書き換えます。

./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

スクリーンショット 2021-06-26 17.59.10.png

localhost:3000

スクリーンショット 2020-12-24 1.30.43.png

それぞれ「localhost:3001」と「localhost:3000」にアクセスしておなじみの画面が表示されれば成功です。

実装

下準備ができたので、本格的な実装に入ります。

バックエンド

モデルを作成

$ docker-compose run api rails g model Todo title:string
$ docker-compose run api rails db:migrate
./backend/app/models/todo.rb
class Todo < ApplicationRecord
  validates :title, presence: true, length: { maximum: 140 }   
end
  • title必須
  • 最大140文字

忘れずにバリデーションも設定。

コントローラーを作成

$ docker-compose run api rails g controller api/v1/todos
./backend/app/controllers/api/v1/todos_controller.rb
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

ルーティングを記述

./backend/config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
     resources :todos, only: [:index, :create, :destroy]
    end 
  end 
end

初期データを作成

./backend/db/seeds.rb
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をインストール

./backend/Gemfile
gem 'rack-cors'

CORSの設定を簡単に行えるgemがあるのでインストールしましょう。APIモードで作成している場合、すでにGemfile内に記載されているのでそちらのコメントアウトを外せばOKです。

$ docker-compose build

Gemfileを更新したので再度ビルド。

cors.rbを編集

./backend/config/initializers/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

今回は全て使う事の無いファイルです。

既存のファイルを編集

また、いくつかファイルを削除した影響で不具合が生じるようになってしまうため、既存のファイルを書き換えます。

./frontend/react-app/src/App.tsx
import React from "react"

const App: React.FC = () => {
  return (
    <h1>Hello React!</h1>
  )
}

export default App
./frontend/react-app/src/index.tsx
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本体
frontend/react-app/src/interfaces/index.ts
export interface Todo {
  id?: number
  title: string
} 
./frontend/react-app/src/lib/api/client.ts
import axios from "axios"

const client = axios.create({
  baseURL: "http://localhost:3001/api/v1"
})

export default client
./frontend/react-app/src/lib/api/todos.ts
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}`)
}
./frontend/react-app/src/components/TodoForm.tsx
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>
  )
}
./frontend/react-app/src/components/TodoList.tsx
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>
  )
}
./frontend/react-app/src/components/TodoItem.tsx
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」を編集します。

./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

完成

スクリーンショット 2020-12-24 18.07.07.png

最終的にこんな感じになっていれば完成です。

あとがき

今回作成したコードのリポジトリを記載しておくので、もし手順通りに進めて動かない部分などがありましたらそちらと比較して間違っている部分などは無いか確認してみてください。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
59
Help us understand the problem. What are the problem?