「React と Rails を利用してTODOアプリを作成しよう」をRails7とReactV18で実装

# はじめに
下記動画を参考に、Rails7はAPIとしてReactV18と切り分けてTODOアプリを実装しました。
ライブラリも2024年11月現在の最新バージョンを適用しているので若干記述を変更しています。

[【React on Rails】React と Rails を利用してTODOアプリを作成しよう（PART1）How to create a Rails project with a React](https://www.youtube.com/watch?v=yD0QotED6f8)

https://www.youtube.com/watch?v=yD0QotED6f8

# 環境
### 対応OS
* Mac OS

### 対応バージョン
* Rails 7.1.5
* React 18.3.1

### 対応エディタ
* VSCode

ローカルで環境開発をしていきます。
RailsとReact、VSCodeはinstall済みとして進めていきます。
cssは`styled-components`のライブラリを使用します。

https://styled-components.com/

# 実装の手順
### - Rails -

#### 1. セットアップ
```:Terminal
rails new todo_app --api -T

# ディレクトリ移動
cd todo_app

# VSCodeの新規ウィンドウで開く
code .
```
:::note info
`--api` ： RailsアプリケーションをAPIモードで作成します。フロントエンド機能が最小限に抑えられ（ビューやアセットパイプラインは基本的に使用しない）、軽量で高速なJSON APIサーバーを構築するのに適しています。

`-T` ： テストフレームワークをスキップします。他のテストフレームワーク（例: RSpec）を使う場合に便利です。
:::

#### 2. 必要なGemを追加
CORS（クロスオリジンリソースシェアリング）は、異なるドメインやポート間でのリソース共有を制御する仕組みです。
RailsをAPIとして使用する際に、Reactなどのフロントエンド環境からのリクエストを許可するために、CORSの設定が必要です。
```rb:Gemfile
# Gemfileに追加またはコメントアウトを外す
gem "rack-cors"
```
```:Terminal
bundle install
```
`config/initializers/cors.rb`で設定をします。
```rb:config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:3001' # ReactサーバーのURL
    resource '*', # すべてのエンドポイントに適用
      headers: :any, # すべてのヘッダーを許可
      methods: [:get, :post, :put, :patch, :delete, :options, :head] # 許可するHTTPメソッドを定義
  end
end
```
:::note info
Rails APIサーバーとReactサーバーのURL（ポート番号）は各自の設定によって異なる可能性があります。各自サーバーの起動時に確認して適宜書き換えてください。
:::
#### 3. Todoモデルとテーブルを追加
```:Terminal
rails g model Todo name:string is_completed:boolean
```
生成されたマイグレーションファイルの中身を修正します。
```rb:db/migrate/XXXX_create_todos.rb
t.string :name, null: false
t.boolean :is_completed, default: false, null: false
```
マイグレーションを実行します。
```:Terminal
rails db:migrate
```
#### 4. seedファイルに初期データを追加
```rb:db/seeds.rb
SAMPLE_TODOS = [
  { name: 'Going around the world' },
  { name: 'Graduating from college' },
  { name: 'Publishing a book' }
]

SAMPLE_TODOS.each { |todo| Todo.create(todo) }
```
データベースに反映させます。
```:Terminal
rails db:seed
```
#### 5. APIエンドポイントの作成
controllers配下にファイルを作成します。
![スクリーンショット 2024-11-12 15.45.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3458437/24ba147d-bc19-c0d8-7a54-7022ffd0029f.png)

`render json`：レスポンスはjson形式で返すようにしています。
```rb:app/controllers/api/v1/todos_controller.rb
module Api
  module V1
    class TodosController < ApplicationController
      def index
        render json: Todo.order(updated_at: :desc)
      end

      def show
        render json: Todo.find(params[:id])
      end

      def create
        todo = Todo.new(todo_params)
        if todo.save
          render json: todo
        else
          render json: { errors: todo.errors }, status: :unprocessable_entity
        end
      end

      def update
        todo = Todo.find(params[:id])
        if todo.update(todo_params)
          render json: todo
        else
          render json: { errors: todo.errors }, status: :unprocessable_entity
        end
      end

      def destroy
        todo = Todo.find(params[:id])
        todo.destroy
        render json: { message: 'Todo deleted successfully' }, status: :ok
      rescue ActiveRecord::RecordNotFound
        render json: { error: 'Todo not found' }, status: :not_found
      rescue StandardError => e
        render json: { error: e.message }, status: :internal_server_error
      end

      def destroy_all
        Todo.destroy_all
        head :no_content
      end

      private

      def todo_params
        params.require(:todo).permit(:name, :is_completed)
      end
    end
  end
end
```
#### 6. ルーティング
`namespace`はプログラム内で名前の衝突を防ぐために使用される名前空間です。
`collection`はリソース全体を操作するようなアクションを追加する場合に使用します。
※ フロント部分はReactのため、以下の記述のみになります。
```rb:config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :todos, only: %i[index show create update destroy] do
        collection do
          delete 'destroy_all'
        end
      end
    end
  end
end
```
### - React -
#### 1. セットアップ
`Select a framework: › React`、`Select a variant: › JavaScript`を選択しました。
```:Terminal
npm create vite@latest frontend --template react

# ディレクトリに移動
cd frontend

# 必要なライブラリのインストール
npm install react-router-dom axios styled-components react-icons react-toastify
```
#### 2. デフォルトの記述の整理
`frontend/src/index.css`の内容を削除します。

`App.css`の内容を書き換えます。
```css:frontend/src/App.css
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  color: rgb(1, 1, 1);
}

h1 {
  text-align: center;
  margin-top: 30px;
  margin-bottom: 15px;
}

a {
  text-decoration: none;
  color: rgb(1, 1, 1);
}

input:focus {
  outline: 0;
}
```
#### 3. 各種ファイルの作成
##### frontend/src/App.jsx
アラートの出力は`react-toastify`を使用します。
使い方の詳細は下記を参照ください。

https://fkhadra.github.io/react-toastify/introduction/

```jsx:frontend/src/App.jsx
import { BrowserRouter as Router,Routes, Route, Link, Navigate } from 'react-router-dom'
import { ToastContainer } from 'react-toastify';
import styled from 'styled-components'
import 'react-toastify/dist/ReactToastify.css';
import AddTodo from './components/AddTodo'
import TodoList from './components/TodoList'
import EditTodo from './components/EditTodo'
import './App.css'

const Nabvar = styled.nav`
  background: #dbfffe;
  min-height: 8vh;
  display: flex;
  justify-content: space-around;
  align-items: center;
`

const Logo = styled.div`
  font-weight: bold;
  font-size: 23px;
  letter-spacing: 3px;
`

const NavItems = styled.ul`
  display: flex;
  width: 400px;
  max-width: 40%;
  justify-content: space-around;
  list-style: none;
`

const NavItem = styled.li`
  font-size: 19px;
  font-weight: bold;
  opacity: 0.7;
  &:hover {
    opacity: 1;
  }
`

const Wrapper = styled.div`
  width: 700px;
  max-width: 85%;
  margin: 20px auto;
`

function App() {
  return (
    <Router>
      <Nabvar>
        <Logo>
          TODO
        </Logo>
        <NavItems>
          <NavItem>
            <Link to="/todos">
              Todos
            </Link>
          </NavItem>
          <NavItem>
            <Link to="/todos/new">
              Add New Todo
            </Link>
          </NavItem>
        </NavItems>
      </Nabvar>
      <Wrapper>
        <Routes>
          <Route path="/" element={<Navigate to="/todos" />} />
          <Route path="/todos" element={<TodoList />} />
          <Route path="/todos/new" element={<AddTodo />} />
          <Route path="/todos/:id/edit" element={<EditTodo />} />
        </Routes>
      </Wrapper>
      <ToastContainer position="bottom-center" hideProgressBar />
    </Router>
  )
}

export default App
```
##### frontend/src/components/TodoList.jsx
```jsx:frontend/src/components/TodoList.jsx
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
import styled from 'styled-components'
import { ImCheckboxChecked, ImCheckboxUnchecked } from 'react-icons/im'
import { AiFillEdit } from 'react-icons/ai'

const SearchAndButtton = styled.div`
  display: flex;
  justify-content: space-between;
  align-items: center;
`

const SearchForm = styled.input`
  font-size: 20px;
  width: 100%;
  height: 40px;
  margin: 10px 0;
  padding: 10px;
`

const RemoveAllButton = styled.button`
  width: 16%;
  height: 40px;
  background: #f54242;
  border: none;
  font-weight: 500;
  margin-left: 10px;
  padding: 5px 10px;
  border-radius: 3px;
  color: #fff;
  cursor: pointer;
`

const TodoName = styled.span.withConfig({
    shouldForwardProp: (prop) => prop !== 'is_completed',
  })`
    font-size: 27px;
    ${({ is_completed }) => is_completed && `
      opacity: 0.4;
    `}
  `;

const Row = styled.div`
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin: 7px auto;
  padding: 10px;
  font-size: 25px;
`

const CheckedBox = styled.div`
  display: flex;
  align-items: center;
  margin: 0 7px;
  color: green;
  cursor: pointer;
`

const UncheckedBox = styled.div`
  display: flex;
  align-items: center;
  margin: 0 7px;
  cursor: pointer;
`

const EditButton = styled.span`
  display: flex;
  align-items: center;
  margin: 0 7px;
`

function TodoList() {
  const [todos, setTodos] = useState([])
  const [searchName, setSearchName] = useState('')

  useEffect(() => {
    axios.get('http://localhost:3000/api/v1/todos')
    .then(resp => {
      console.log(resp.data)
      setTodos(resp.data);
    })
    .catch(e => {
      console.log(e);
    })
  }, [])

  const removeAllTodos = () => {
    const sure = window.confirm('Are you sure?');
    if (sure) {
      axios.delete('http://localhost:3000/api/v1/todos/destroy_all')
      .then(() => {
        setTodos([])
      })
      .catch(e => {
        console.log(e)
      })
    }
  }

  const updateIsCompleted = (index, val) => {
    var data = {
      id: val.id,
      name : val.name,
      is_completed: !val.is_completed
    }
    axios.patch(`http://localhost:3000/api/v1/todos/${val.id}`, data)
    .then(resp => {
      const newTodos = [...todos]
      newTodos[index].is_completed = resp.data.is_completed
      setTodos(newTodos)
    })
  }

  return (
    <>
      <h1>Todo List</h1>
      <SearchAndButtton>
        <SearchForm
          type="text"
          placeholder="Search todo..."
          onChange={event => {
            setSearchName(event.target.value)
          }}
        />
        <RemoveAllButton onClick={removeAllTodos}>
          Remove All
        </RemoveAllButton>
      </SearchAndButtton>

      <div>
        {todos.filter((val) => {
          if(searchName === "") {
            return val
          } else if (val.name.toLowerCase().includes(searchName.toLowerCase())) {
            return val
          }
        }).map((val, key) => {
          return (
            <Row key={key}>
              {val.is_completed ? (
                <CheckedBox>
                  <ImCheckboxChecked onClick={() => updateIsCompleted(key, val) } />
                </CheckedBox>
              ) : (
                <UncheckedBox>
                  <ImCheckboxUnchecked onClick={() => updateIsCompleted(key, val) } />
                </UncheckedBox>
              )}
              <TodoName is_completed={val.is_completed}>
                {val.name}
              </TodoName>
              <Link to={"/todos/" + val.id + "/edit"}>
                <EditButton>
                  <AiFillEdit />
                </EditButton>
              </Link>
            </Row>
          )
        })}
      </div>
    </>
  )
}

export default TodoList
```
##### frontend/src/components/AddTodo.jsx
```jsx:frontend/src/components/AddTodo.jsx
import { useState } from 'react'
import axios from 'axios'
import styled from 'styled-components'
import { toast } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.css'
import { FiSend } from 'react-icons/fi'
import { useNavigate} from 'react-router-dom'

const InputAndButton = styled.div`
  display: flex;
  justify-content: space-between;
  margin-top: 20px;
`

const InputName = styled.input`
  font-size: 20px;
  width: 100%;
  height: 40px;
  padding: 2px 7px;
`

const Button = styled.button`
  font-size: 20px;
  border: none;
  border-radius: 3px;
  margin-left: 10px;
  padding: 2px 10px;
  background: #1E90FF;
  color: #fff;
  text-align: center;
  cursor: pointer;
  ${({ disabled }) => disabled && `
    opacity: 0.5;
    cursor: default;
  `}
`

const Icon = styled.span`
  display: flex;
  align-items: center;
  margin: 0 7px;
`

function AddTodo() {
  const initialTodoState = {
    id: null,
    name: "",
    is_completed: false
  };

  const [todo, setTodo] = useState(initialTodoState);
  const navigate = useNavigate(); 

  const notify = () => {
    toast.success("Todo successfully created!", {
      position: "bottom-center",
      hideProgressBar: true
    });
  }

  const handleInputChange = event => {
    const { name, value } = event.target;
    setTodo({ ...todo, [name]: value });
  };

  const saveTodo = () => {
    var data = {
      name: todo.name,
    };

    axios.post('http://localhost:3000/api/v1/todos', data)
    .then(resp => {
      setTodo({
        id: resp.data.id,
        name: resp.data.name,
        is_completed: resp.data.is_completed
      });
      notify();
      navigate("/todos");
    })
    .catch(e => {
      console.log(e)
    })
  };


  return (
    <>
      <h1>New Todo</h1>
      <InputAndButton>
        <InputName
          type="text"
          required
          value={todo.name}
          onChange={handleInputChange}
          name="name"
        />
        <Button
          onClick={saveTodo}
          disabled={(!todo.name || /^\s*$/.test(todo.name))}
        >
          <Icon>
            <FiSend />
          </Icon>
        </Button>
      </InputAndButton>
    </>
  )
}

export default AddTodo
```
##### frontend/src/components/EditTodo.jsx
```jsx:frontend/src/components/EditTodo.jsx
import { useState, useEffect } from "react"
import axios from 'axios'
import styled from 'styled-components'
import { toast } from 'react-toastify'
import { useNavigate, useParams } from 'react-router-dom'
import 'react-toastify/dist/ReactToastify.css'

const InputName = styled.input`
  font-size: 20px;
  width: 100%;
  height: 40px;
  padding: 2px 7px;
  margin: 12px 0;
`

const CurrentStatus = styled.div`
  font-size: 19px;
  margin: 8px 0 12px 0;
  font-weight: bold;
`

const IsCompeletedButton = styled.button`
  color: #fff;
  font-weight: 500;
  font-size: 17px;
  padding: 5px 10px;
  background: #f2a115;
  border: none;
  border-radius: 3px;
  cursor: pointer;
`

const EditButton = styled.button`
  color: white;
  font-weight: 500;
  font-size: 17px;
  padding: 5px 10px;
  margin: 0 10px;
  background: #0ac620;
  border-radius: 3px;
  border: none;
`

const DeleteButton = styled.button`
  color: #fff;
  font-size: 17px;
  font-weight: 500;
  padding: 5px 10px;
  background: #f54242;
  border: none;
  border-radius: 3px;
  cursor: pointer;
`

function EditTodo() {
  const initialTodoState = {
    id: null,
    name: "",
    is_completed: false
  };

  const { id } = useParams();

  const [currentTodo, setCurrentTodo] = useState(initialTodoState);
  const navigate = useNavigate(); 

  const notify = () => {
    toast.success("Todo successfully updated!", {
      position: "bottom-center",
      hideProgressBar: true
    });
  }

  const getTodo = id => {
    axios.get(`http://localhost:3000/api/v1/todos/${id}`)
    .then(resp => {
      setCurrentTodo(resp.data);
    })
    .catch(e => {
      console.log(e);
    });
  };

  useEffect(() => {
    if (id) {
      getTodo(id); // useParamsから取得したidを使用
    }
  }, [id]);

  const handleInputChange = event => {
    const { name, value } = event.target;
    setCurrentTodo({ ...currentTodo, [name]: value });
  };

  const updateIsCompleted = (val) => {
    var data = {
      id: val.id,
      name: val.name,
      is_completed: !val.is_completed
    };
    axios.patch(`http://localhost:3000/api/v1/todos/${val.id}`, data)
    .then(resp => {
      setCurrentTodo(resp.data);
    })
  };

  const updateTodo = () => {
    axios.patch(`http://localhost:3000/api/v1/todos/${currentTodo.id}`, currentTodo)
    .then(() => {
      notify();
      navigate("/todos");
    })
    .catch(e => {
      console.log(e);
    });
  };

  const deleteTodo = () => {
    const sure = window.confirm('Are you sure?');
    if (sure) {
      axios.delete(`http://localhost:3000/api/v1/todos/${currentTodo.id}`)
      .then(() => {
        navigate("/todos");
      })
      .catch(e => {
        console.log(e);
      });
    }
  };

  return (
    <>
      <h1>Editing Todo</h1>
      <div>
        <div>
          <label htmlFor="name">Current Name</label>
          <InputName
            type="text"
            id="name"
            name="name"
            value={currentTodo.name}
            onChange={handleInputChange}
          />
          <div>
            <span>CurrentStatus</span><br/>
            <CurrentStatus>
              {currentTodo.is_completed ? "Completed" : "UnCompleted"}
            </CurrentStatus>
          </div>
        </div>

        {currentTodo.is_completed ? (
          <IsCompeletedButton
            className="badge badge-primary mr-2"
            onClick={() => updateIsCompleted(currentTodo)}
          >
            UnCompleted
          </IsCompeletedButton>
        ) : (
          <IsCompeletedButton
            className="badge badge-primary mr-2"
            onClick={() => updateIsCompleted(currentTodo)}
          >
            Completed
          </IsCompeletedButton>
        )}
        <EditButton
          type="submit"
          onClick={updateTodo}
        >
          Update
        </EditButton>
        <DeleteButton
          className="badge badge-danger mr-2"
          onClick={deleteTodo}
        >
          Delete
        </DeleteButton>

      </div>
    </>
  );
};

export default EditTodo
```
### RailsとReactのサーバーを連携
```:Terminal
# Rails APIサーバーの起動(localhost:3000)
rails s

# Reactサーバーの起動(localhost:3001)
cd frontend
npm run dev
```
:::note info
RailsとReact用にTerminalを分割しておくと便利です。
frontendディレクトリにいる場合は`cd ../`でRails APIサーバーのディレクトリに移動できます。

:::
RailsサーバーのURLをたたけば、APIを呼び出すことができます。
`http://localhost:3000/api/v1/todos`
実装の内容はReactサーバーの起動時のURLにアクセスしてください。

# 最後に
初心者ながら[youtube](https://www.youtube.com/watch?v=yD0QotED6f8)を参考に色々調べて実装しました。
エラーが出ては修正したため、一部動画の内容とコードが異なる部分があります。ご了承ください。
上記コードはリファクタリングが必要と思いますが、Rails7はAPIとして、ReactV18と切り分けて実装するイメージとして掴んだいただけたらと思います。

# 参考文献
[【React on Rails】React と Rails を利用してTODOアプリを作成しよう（PART1）How to create a Rails project with a React](https://www.youtube.com/watch?v=yD0QotED6f8)

https://www.youtube.com/watch?v=yD0QotED6f8