はじめに
下記動画を参考に、Rails7はAPIとしてReactV18と切り分けてTODOアプリを実装しました。
ライブラリも2024年11月現在の最新バージョンを適用しているので若干記述を変更しています。
【React on Rails】React と Rails を利用してTODOアプリを作成しよう(PART1)How to create a Rails project with a React
環境
対応OS
- Mac OS
対応バージョン
- Rails 7.1.5
- React 18.3.1
対応エディタ
- VSCode
ローカルで環境開発をしていきます。
RailsとReact、VSCodeはinstall済みとして進めていきます。
cssはstyled-components
のライブラリを使用します。
実装の手順
- Rails -
1. セットアップ
rails new todo_app --api -T
# ディレクトリ移動
cd todo_app
# VSCodeの新規ウィンドウで開く
code .
--api
: RailsアプリケーションをAPIモードで作成します。フロントエンド機能が最小限に抑えられ(ビューやアセットパイプラインは基本的に使用しない)、軽量で高速なJSON APIサーバーを構築するのに適しています。
-T
: テストフレームワークをスキップします。他のテストフレームワーク(例: RSpec)を使う場合に便利です。
2. 必要なGemを追加
CORS(クロスオリジンリソースシェアリング)は、異なるドメインやポート間でのリソース共有を制御する仕組みです。
RailsをAPIとして使用する際に、Reactなどのフロントエンド環境からのリクエストを許可するために、CORSの設定が必要です。
# Gemfileに追加またはコメントアウトを外す
gem "rack-cors"
bundle install
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
Rails APIサーバーとReactサーバーのURL(ポート番号)は各自の設定によって異なる可能性があります。各自サーバーの起動時に確認して適宜書き換えてください。
3. Todoモデルとテーブルを追加
rails g model Todo name:string is_completed:boolean
生成されたマイグレーションファイルの中身を修正します。
t.string :name, null: false
t.boolean :is_completed, default: false, null: false
マイグレーションを実行します。
rails db:migrate
4. seedファイルに初期データを追加
SAMPLE_TODOS = [
{ name: 'Going around the world' },
{ name: 'Graduating from college' },
{ name: 'Publishing a book' }
]
SAMPLE_TODOS.each { |todo| Todo.create(todo) }
データベースに反映させます。
rails db:seed
5. APIエンドポイントの作成
render json
:レスポンスはjson形式で返すようにしています。
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のため、以下の記述のみになります。
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
を選択しました。
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
の内容を書き換えます。
* {
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
を使用します。
使い方の詳細は下記を参照ください。
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
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
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
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のサーバーを連携
# Rails APIサーバーの起動(localhost:3000)
rails s
# Reactサーバーの起動(localhost:3001)
cd frontend
npm run dev
RailsとReact用にTerminalを分割しておくと便利です。
frontendディレクトリにいる場合はcd ../
でRails APIサーバーのディレクトリに移動できます。
RailsサーバーのURLをたたけば、APIを呼び出すことができます。
http://localhost:3000/api/v1/todos
実装の内容はReactサーバーの起動時のURLにアクセスしてください。
最後に
初心者ながらyoutubeを参考に色々調べて実装しました。
エラーが出ては修正したため、一部動画の内容とコードが異なる部分があります。ご了承ください。
上記コードはリファクタリングが必要と思いますが、Rails7はAPIとして、ReactV18と切り分けて実装するイメージとして掴んだいただけたらと思います。
参考文献
【React on Rails】React と Rails を利用してTODOアプリを作成しよう(PART1)How to create a Rails project with a React