第5章: React + TypeScript入門と基礎統合
🎯 この章で学ぶこと(所要時間: 3.5時間)
TypeScriptの基礎を学び、Rails環境にReactを統合します。react_on_railsとrewrapを使って、シンプルなTodoアプリのUIを構築します。
📢 はじめに
第4章までで、セキュアなRails APIが完成しました。いよいよフロントエンドの構築に入ります。
今回は、TypeScriptという「型のあるJavaScript」を使います。TypeScriptを使うと、コードを書いている時点でエラーに気づけるので、バグが減り、開発効率が上がります。
また、RailsとReactを組み合わせる方法として、react_on_railsとrewrapを使います。これにより、Railsの良さを活かしながら、モダンなフロントエンドを構築できます。
🎓 この章を終えると...
- TypeScriptの基本的な型が理解できる
- VSCodeで快適な開発環境が構築できる
- RailsにReactを統合する方法がわかる
- シンプルなTodoアプリのUIが作れる
- CSS Modulesでスタイリングができる
💡 この章で身につくスキル
- 📘 TypeScriptの基本(型注釈、インターフェース)
- 🛠️ VSCode + ESLint + Prettierの設定
- ⚛️ React基本コンポーネントの作成
- 🔧 react_on_railsとrewrapの活用
- 🎨 CSS Modulesによるスタイリング
- 🌐 APIとの基本的な通信
- セクション0: 作業ブランチの作成(実践)
- セクション1: 開発環境の整備(実践)
- セクション2: TypeScript入門(読みながら実践)
- セクション3: React + Rails統合(実践)
- セクション4: 基本UIの実装(実践)
- セクション5: 動作確認(実践)
- 第4章までの実装が完了していること
- Node.js 18以上がインストールされていること
- VSCodeがインストールされていること
- Dockerコンテナが正常に動作していること
それでは、モダンなフロントエンド開発を始めます!
📚 0. 作業ブランチの作成
新しい機能開発なので、新しいブランチを作成します。
🔨 実際にやってみましょう!
# 現在のブランチを確認
git branch
# mainブランチに切り替え
git checkout main
# 最新の状態に更新
git pull origin main
# feature/chapter-5ブランチを作成して切り替え
git checkout -b feature/chapter-5
# ブランチが切り替わったか確認
git branch
1. 開発環境の整備
🛠️ VSCode拡張機能のインストール
効率的な開発のために、VSCodeに以下の拡張機能をインストールします。
🔨 実際にやってみましょう!
VSCodeを開いて、拡張機能タブ(左サイドバーの四角いアイコン)から以下を検索してインストール:
- ESLint - コードの問題を検出
- Prettier - Code formatter - コードを自動整形
- TypeScript React code snippets - React開発を効率化
- Ruby - Rubyファイルのシンタックスハイライト
- Simple Ruby ERB - ERBファイルのサポート
📝 プロジェクト設定ファイルの作成
🔨 実際にやってみましょう!
プロジェクトのルートディレクトリに.vscodeフォルダを作成し、設定ファイルを追加します:
# .vscodeディレクトリを作成
mkdir -p .vscode
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"typescript.preferences.importModuleSpecifier": "relative",
"files.associations": {
"*.css": "css"
}
}
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false
}
# 無視するファイル/ディレクトリ
node_modules/
public/
tmp/
log/
.git/
coverage/
vendor/
これらの設定により:
- 保存時に自動でコードが整形される
- 一貫したコードスタイルが保たれる
- チーム開発でも同じ設定を共有できる
2. TypeScript入門
📘 TypeScriptとは?
TypeScriptは「型のあるJavaScript」です。JavaScriptにコンパイル時の型チェックを追加したものと考えてください。
なぜTypeScriptを使うの?
// JavaScriptの場合 - 実行するまでエラーに気づかない
function greet(name) {
return "Hello, " + name.toUpperCase();
}
greet(123); // 実行時エラー: toUpperCase is not a function
// TypeScriptの場合 - 書いた時点でエラーがわかる
function greet(name: string): string {
return "Hello, " + name.toUpperCase();
}
greet(123); // コンパイルエラー: 数値を文字列型に代入できません
TypeScriptの3つのメリット
-
開発中にエラーを発見
- タイポや型の不一致をVSCodeがリアルタイムで指摘
- 「undefined」エラーを事前に防げる
-
エディタの補完が賢くなる
- プロパティ名を自動補完
- メソッドの引数を教えてくれる
- ドキュメントを見る回数が減る
-
リファクタリングが安全
- 変数名の変更が漏れなく反映
- 不要なコードを安全に削除
- 大規模な変更も自信を持って実行
🎯 基本的な型
🔨 実際にやってみましょう!
以下の内容でplayground.tsファイルを作成して、TypeScriptを体験してみましょう:
# playgroundファイルを作成
touch playground.ts
# VSCodeで開く
code playground.ts
// 1. 基本的な型注釈
let userName: string = "Alice";
let age: number = 25;
let isActive: boolean = true;
// 型注釈を忘れるとどうなる?
let someValue = "初期値"; // TypeScriptが自動でstring型と推論
// someValue = 123; // エラー!型推論により文字列型に固定されている
// 2. 配列の型
let numbers: number[] = [1, 2, 3, 4, 5];
let names: string[] = ["Alice", "Bob", "Charlie"];
// 別の書き方(ジェネリクス)
let scores: Array<number> = [100, 85, 92];
// 3. オブジェクトの型(インターフェース)
interface User {
id: number;
name: string;
email: string;
isAdmin?: boolean; // ?は省略可能を意味
}
const user: User = {
id: 1,
name: "Alice",
email: "alice@example.com"
// isAdminは省略可能なので、なくてもOK
};
// オブジェクトの型注釈の恩恵
user.name.toUpperCase(); // OK: nameはstring型
// user.age; // エラー: ageプロパティは存在しない
// 4. 関数の型
function add(a: number, b: number): number {
return a + b;
}
// アロー関数の場合
const multiply = (a: number, b: number): number => {
return a * b;
};
// 返り値の型は推論可能(省略できる)
const divide = (a: number, b: number) => {
return a / b; // number型と推論される
};
// 5. Union型(どちらかの型)
let result: string | number;
result = "success"; // OK
result = 200; // OK
// result = true; // エラー: boolean型は代入できません
// Union型の実用例
function formatPrice(price: number | string): string {
if (typeof price === "number") {
return `¥${price.toFixed(0)}`;
} else {
return price; // すでに文字列フォーマット済み
}
}
// 6. 型推論(TypeScriptが自動で型を判断)
let message = "Hello"; // string型と推論される
// message = 123; // エラー: 数値を代入できません
// 7. Literal型(特定の値のみ許可)
let status: "pending" | "success" | "error";
status = "success"; // OK
// status = "failed"; // エラー: "failed"は許可されていない
// 8. 型エイリアス(型に別名をつける)
type ID = string | number;
type TodoStatus = "todo" | "doing" | "done";
let userId: ID = "user_123";
let taskStatus: TodoStatus = "doing";
🔍 VSCodeでTypeScriptを体験
🔨 実際にやってみましょう!
- VSCodeで
playground.tsを開く - エラーがある行に赤い波線が表示される
- 変数にカーソルを合わせると型情報が表示される
user.と入力すると、利用可能なプロパティが自動補完される
// これを試してみよう!
const testUser: User = {
id: 2,
name: "Bob",
email: "bob@example.com"
};
// testUser. と入力すると...
testUser. // ← ここで自動補完が表示される!
💡 TypeScriptの実践的なテクニック
1. 型ガード(Type Guards)
// 型を安全に判定する方法
function processValue(value: string | number | null) {
// nullチェック
if (value === null) {
console.log("値がありません");
return;
}
// 型の判定
if (typeof value === "string") {
console.log("文字数:", value.length);
} else {
console.log("2倍:", value * 2);
}
}
2. Optional Chaining(?.)
interface Address {
street?: string;
city?: string;
}
interface Person {
name: string;
address?: Address;
}
const person: Person = { name: "Alice" };
// 安全にネストしたプロパティにアクセス
const city = person.address?.city; // undefined(エラーにならない)
3. デフォルト値との組み合わせ
// Nullish Coalescing(??)を使う
const displayName = user.name ?? "ゲストユーザー";
// 関数のデフォルト引数
function greetUser(name: string = "ゲスト") {
console.log(`こんにちは、${name}さん!`);
}
📝 ReactでのTypeScript
Reactコンポーネントを書く時の基本的なパターン:
基本的なコンポーネントの型定義
// Reactコンポーネントの型定義
interface TodoItemProps {
title: string;
completed: boolean;
onToggle: () => void;
}
// 関数コンポーネント
const TodoItem: React.FC<TodoItemProps> = ({ title, completed, onToggle }) => {
return (
<div>
<input type="checkbox" checked={completed} onChange={onToggle} />
<span>{title}</span>
</div>
);
};
// useStateの型定義
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState<boolean>(false);
イベントハンドラーの型
// よく使うイベントの型
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log("クリックされました");
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log("入力値:", e.target.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log("フォーム送信");
};
子コンポーネントへの関数の受け渡し
// 親コンポーネント
const ParentComponent = () => {
const handleTodoAdd = (title: string) => {
console.log("新しいTodo:", title);
};
return <ChildComponent onAdd={handleTodoAdd} />;
};
// 子コンポーネント
interface ChildComponentProps {
onAdd: (title: string) => void;
}
const ChildComponent: React.FC<ChildComponentProps> = ({ onAdd }) => {
return (
<button onClick={() => onAdd("新しいタスク")}>
追加
</button>
);
};
TypeScriptは最初は面倒に感じるかもしれませんが、慣れると手放せなくなります。
- エディタが補完してくれる
- タイポをすぐに見つけられる
- リファクタリングが安全にできる
- チーム開発で意図が明確になる
学習のコツ: 最初はany型を使って逃げてもOK!徐々に正しい型に置き換えていけば良いのです。
// 最初はこれでもOK
const data: any = fetchSomeData();
// 後で型を定義して置き換える
interface Data {
id: number;
name: string;
}
const data: Data = fetchSomeData();
3. React + Rails統合
🤔 なぜreact_on_railsとrewrapを使うの?
RailsにReactを統合する方法はいくつかありますが、今回はこの組み合わせを選びました:
-
react_on_rails: Rails環境でReactを使うための定番gem
- サーバーサイドレンダリング(SSR)対応
- Rails資産パイプラインとの統合
- プロダクション環境での実績多数
-
rewrap: ReactコンポーネントをWeb Componentsに変換
- ERBテンプレート内で
<todo-list></todo-list>のように使える - 段階的な移行が可能(一部だけReact化)
- RailsのTurboとの相性が良い
- ERBテンプレート内で
🏗️ アーキテクチャの全体像
🚀 react_on_railsのセットアップ
🔨 実際にやってみましょう!
Gemfileに追加:
# React-Rails統合
gem 'react_on_rails', '~> 13.4'
Gemをインストール:
docker compose exec web bundle install
# react_on_railsの初期設定
docker compose exec web rails generate react_on_rails:install
# Dockerイメージを再ビルド(package.jsonが更新されたため)
docker compose build web
docker compose up -d
📦 必要なパッケージの追加
🔨 実際にやってみましょう!
rewrapとTypeScript関連のパッケージをインストール:
# rewrapと開発に必要なパッケージをインストール
docker compose exec web npm install @mrhenry/rewrap
# 型定義ファイルをインストール
docker compose exec web npm install -D @types/react @types/react-dom
# ESLintとPrettierをインストール
docker compose exec web npm install -D \
eslint \
@typescript-eslint/parser \
@typescript-eslint/eslint-plugin \
eslint-plugin-react \
eslint-plugin-react-hooks \
eslint-config-prettier \
prettier
🔧 Vite設定の更新
🔨 実際にやってみましょう!
import { defineConfig } from 'vite';
import RubyPlugin from 'vite-plugin-ruby';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [
RubyPlugin(),
react(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './app/frontend'),
},
},
server: {
hmr: {
host: 'localhost',
},
},
});
🎯 エントリーポイントの作成
🔨 実際にやってみましょう!
// React and rewrap imports
import React from 'react';
import ReactDOM from 'react-dom';
import rewrap from '@mrhenry/rewrap';
// コンポーネントのインポート
import TodoList from '@/components/TodoList';
// Web Componentsとして登録
// これにより、ERBで<todo-list></todo-list>として使える
rewrap('todo-list', TodoList);
console.log('Frontend entry point loaded!');
🔍 rewrapの仕組み
rewrapは何をしているのか詳しく見てみましょう:
// rewrapの内部動作イメージ
rewrap('todo-list', TodoList);
// ↓
// 1. カスタムエレメント(Web Components)を定義
// 2. <todo-list>タグを見つけたら
// 3. ReactコンポーネントをそこにマウントG
メリット:
- ERBとReactの共存が簡単
- 属性経由でデータを渡せる
- ページの一部だけReact化できる
<!-- ERBでの使用例 -->
<todo-list user-id="<%= current_user.id %>"></todo-list>
4. 基本UIの実装
📐 コンポーネント設計の考え方
ReactでUIを作る前に、コンポーネントの設計方針を理解しましょう。
単一責任の原則
各コンポーネントは1つの責務を持ちます:
TodoList(親)
├─ AddTodoForm(Todo追加フォーム)
└─ TodoItem(個別のTodo表示)× n個
プレゼンテーショナルとコンテナの分離
// プレゼンテーショナルコンポーネント(見た目だけ)
const TodoItem = ({ todo, onToggle, onDelete }) => {
// UIの表示のみに集中
};
// コンテナコンポーネント(ロジック担当)
const TodoList = () => {
// 状態管理とビジネスロジック
const [todos, setTodos] = useState([]);
// API通信など
};
📂 ディレクトリ構造の作成
🔨 実際にやってみましょう!
# フロントエンドのディレクトリ構造を作成
mkdir -p app/frontend/{components,types,styles,utils}
📝 型定義の作成
🔨 実際にやってみましょう!
export interface Todo {
id: number;
title: string;
description?: string;
completed: boolean;
created_at: string;
updated_at: string;
}
export interface CreateTodoInput {
title: string;
description?: string;
}
⚛️ TodoListコンポーネント
🔨 実際にやってみましょう!
import React, { useState, useEffect } from 'react';
import { Todo, CreateTodoInput } from '@/types/todo';
import TodoItem from './TodoItem';
import AddTodoForm from './AddTodoForm';
import styles from './TodoList.module.css';
const TodoList: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Todoを取得
useEffect(() => {
fetchTodos();
}, []);
const fetchTodos = async () => {
try {
const response = await fetch('/api/v1/todos?user_id=1'); // 一時的にuser_id=1を使用
if (!response.ok) throw new Error('Failed to fetch todos');
const data = await response.json();
setTodos(data.todos);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
const handleAddTodo = async (input: CreateTodoInput) => {
try {
const response = await fetch('/api/v1/todos?user_id=1', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ todo: input }),
});
if (!response.ok) throw new Error('Failed to create todo');
const newTodo = await response.json();
setTodos([...todos, newTodo]);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
}
};
const handleToggleTodo = async (id: number) => {
const todo = todos.find(t => t.id === id);
if (!todo) return;
try {
const response = await fetch(`/api/v1/todos/${id}?user_id=1`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ todo: { completed: !todo.completed } }),
});
if (!response.ok) throw new Error('Failed to update todo');
const updatedTodo = await response.json();
setTodos(todos.map(t => t.id === id ? updatedTodo : t));
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
}
};
const handleDeleteTodo = async (id: number) => {
try {
const response = await fetch(`/api/v1/todos/${id}?user_id=1`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete todo');
setTodos(todos.filter(t => t.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
}
};
if (loading) return <div className={styles.loading}>読み込み中...</div>;
if (error) return <div className={styles.error}>エラー: {error}</div>;
return (
<div className={styles.container}>
<h2 className={styles.title}>Todoリスト</h2>
<AddTodoForm onAdd={handleAddTodo} />
<div className={styles.todoList}>
{todos.length === 0 ? (
<p className={styles.empty}>Todoがありません</p>
) : (
todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => handleToggleTodo(todo.id)}
onDelete={() => handleDeleteTodo(todo.id)}
/>
))
)}
</div>
</div>
);
};
export default TodoList;
📋 TodoItemコンポーネント
🔨 実際にやってみましょう!
import React from 'react';
import { Todo } from '@/types/todo';
import styles from './TodoItem.module.css';
interface TodoItemProps {
todo: Todo;
onToggle: () => void;
onDelete: () => void;
}
const TodoItem: React.FC<TodoItemProps> = ({ todo, onToggle, onDelete }) => {
return (
<div className={styles.todoItem}>
<input
type="checkbox"
checked={todo.completed}
onChange={onToggle}
className={styles.checkbox}
/>
<div className={styles.content}>
<h3 className={todo.completed ? styles.completed : ''}>
{todo.title}
</h3>
{todo.description && (
<p className={styles.description}>{todo.description}</p>
)}
</div>
<button
onClick={onDelete}
className={styles.deleteButton}
aria-label="削除"
>
×
</button>
</div>
);
};
export default TodoItem;
➕ AddTodoFormコンポーネント
🔨 実際にやってみましょう!
import React, { useState } from 'react';
import { CreateTodoInput } from '@/types/todo';
import styles from './AddTodoForm.module.css';
interface AddTodoFormProps {
onAdd: (todo: CreateTodoInput) => void;
}
const AddTodoForm: React.FC<AddTodoFormProps> = ({ onAdd }) => {
const [title, setTitle] = useState<string>('');
const [description, setDescription] = useState<string>('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
onAdd({
title: title.trim(),
description: description.trim() || undefined,
});
setTitle('');
setDescription('');
};
return (
<form onSubmit={handleSubmit} className={styles.form}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Todoを入力..."
className={styles.titleInput}
/>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="詳細(任意)"
className={styles.descriptionInput}
/>
<button type="submit" className={styles.addButton}>
追加
</button>
</form>
);
};
export default AddTodoForm;
🎨 CSS Modulesでスタイリング
CSS Modulesとは?
CSS ModulesはCSSをコンポーネントごとにスコープ化する仕組みです。
従来のCSSの問題:
/* global.css */
.title {
color: blue;
}
/* 別の場所でも.titleを使うと... */
.title {
color: red; /* 競合!どちらが適用される? */
}
CSS Modulesの解決策:
/* TodoList.module.css */
.title {
color: blue;
}
/* 実際のクラス名: TodoList_title_x7d8f9 のようにユニークになる */
使い方:
import styles from './TodoList.module.css';
// クラス名はオブジェクトのプロパティとしてアクセス
<h1 className={styles.title}>タイトル</h1>
🔨 実際にやってみましょう!
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.title {
font-size: 24px;
font-weight: bold;
margin-bottom: 20px;
text-align: center;
}
.todoList {
margin-top: 20px;
}
.empty {
text-align: center;
color: #666;
margin: 40px 0;
}
.loading {
text-align: center;
color: #666;
margin: 40px 0;
}
.error {
color: #d32f2f;
text-align: center;
margin: 40px 0;
}
.todoItem {
display: flex;
align-items: center;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 8px;
background: white;
transition: box-shadow 0.2s;
}
.todoItem:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.checkbox {
width: 20px;
height: 20px;
margin-right: 12px;
cursor: pointer;
}
.content {
flex: 1;
}
.content h3 {
margin: 0 0 4px;
font-size: 16px;
}
.completed {
text-decoration: line-through;
color: #666;
}
.description {
margin: 0;
font-size: 14px;
color: #666;
}
.deleteButton {
background: #f44336;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 18px;
cursor: pointer;
transition: background 0.2s;
}
.deleteButton:hover {
background: #d32f2f;
}
.form {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.titleInput {
flex: 2;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.descriptionInput {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.addButton {
padding: 8px 16px;
background: #2196f3;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.addButton:hover {
background: #1976d2;
}
5. Railsとの統合
🌐 ERB統合の仕組み
RailsとReactがどのように連携するか、流れを理解しましょう:
ERBとReactの役割分担
| 要素 | 担当 | 理由 |
|---|---|---|
| ページレイアウト | ERB | SEO、初期表示速度 |
| 動的UI | React | インタラクティブ性 |
| ルーティング | Rails | URLベースのページ遷移 |
| 状態管理 | React | UIの状態変化 |
🔐 認証の一時無効化
開発を簡単にするため、一時的に認証を無効にします。
🔨 実際にやってみましょう!
class ApplicationController < ActionController::API
include ActionController::Cookies
include ActionController::RequestForgeryProtection
protect_from_forgery with: :exception
# before_action :require_authentication # 一時的にコメントアウト
def health
render json: { status: 'ok' }
end
private
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end
def require_authentication
unless current_user
render json: { error: '認証が必要です' }, status: :unauthorized
end
end
end
認証の無効化は開発時のみの措置です。本番環境では必ず有効化してください。 第6章で認証を再度有効化します。
🏠 ホームコントローラーの作成
🔨 実際にやってみましょう!
docker compose exec web rails generate controller home index
class HomeController < ApplicationController
def index
# ERBテンプレートをレンダリング
end
end
🛤️ ルーティングの設定
🔨 実際にやってみましょう!
Rails.application.routes.draw do
# ホームページ
root 'home#index'
# ヘルスチェック
get '/health', to: 'application#health'
namespace :api do
namespace :v1 do
# 認証関連
post 'login', to: 'sessions#create'
delete 'logout', to: 'sessions#destroy'
get 'me', to: 'sessions#show'
# Todo関連
resources :todos do
collection do
get :stats
end
end
end
end
end
📄 ERBテンプレートの作成
🔨 実際にやってみましょう!
<!DOCTYPE html>
<html>
<head>
<title>Todo App</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= vite_client_tag %>
<%= vite_javascript_tag 'frontend' %>
<%= vite_stylesheet_tag 'application' %>
</head>
<body>
<%= yield %>
</body>
</html>
<div class="app-container">
<h1>Todoアプリケーション</h1>
<!-- Reactコンポーネントを埋め込み -->
<todo-list></todo-list>
</div>
<style>
.app-container {
padding: 20px;
background-color: #f5f5f5;
min-height: 100vh;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 40px;
}
</style>
🔧 ApplicationControllerの修正
APIコントローラーがHTMLも返せるように修正:
🔨 実際にやってみましょう!
class ApplicationController < ActionController::Base # APIからBaseに変更
include ActionController::Cookies
protect_from_forgery with: :exception
# before_action :require_authentication # 一時的にコメントアウト
# APIコントローラーのみJSONを返す
def render_json_error(message, status)
render json: { error: message }, status: status
end
private
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end
def require_authentication
unless current_user
respond_to do |format|
format.html { redirect_to root_path, alert: 'ログインが必要です' }
format.json { render_json_error('認証が必要です', :unauthorized) }
end
end
end
end
APIコントローラーは引き続きAPIモードで動作:
module Api
module V1
class BaseController < ApplicationController
# APIコントローラーの共通設定
before_action :set_default_format
private
def set_default_format
request.format = :json
end
end
end
end
各APIコントローラーを修正:
module Api
module V1
class TodosController < BaseController # ApplicationControllerからBaseControllerに変更
# ... 既存のコード ...
end
end
end
6. 動作確認とデバッグ
🐛 開発ツールの活用
Chrome DevToolsでReactをデバッグ
React Developer Toolsをインストール:
- Chrome拡張機能ストアで「React Developer Tools」を検索
- インストール後、DevToolsに「Components」タブが追加される
使い方:
// コンポーネントの状態を確認
// 1. ComponentsタブでTodoListを選択
// 2. 右側のパネルでstate、propsを確認
// 3. 値を直接編集して動作確認も可能
VSCodeでのデバッグ設定
🔨 実際にやってみましょう!
.vscode/launch.jsonを作成:
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/app/frontend",
"sourceMaps": true
}
]
}
これにより、VSCode内でブレークポイントを設定してデバッグできます。
🚀 サーバーの起動
🔨 実際にやってみましょう!
# Railsサーバーとフロントエンドのビルドを起動
docker compose exec web bin/vite dev
別のターミナルで:
# Railsサーバーを起動
docker compose exec web rails server -b 0.0.0.0
🌐 ブラウザで確認
- http://localhost:3000 にアクセス
- 「Todoアプリケーション」というタイトルが表示される
- TodoListコンポーネントが表示される
- Todoの追加・完了・削除ができることを確認
🧪 動作テスト
以下の操作を試してみましょう:
- Todo追加: 「買い物に行く」と入力して追加ボタンをクリック
- 完了状態の切り替え: チェックボックスをクリック
- 削除: ×ボタンをクリック
🔍 よくあるエラーと対処法
エラー1: Viteが起動しない
# エラー: EADDRINUSE: address already in use
# 解決策: ポートを使用しているプロセスを終了
lsof -i :5173
kill -9 [PID]
エラー2: TypeScriptの型エラー
// エラー: Property 'todo' does not exist on type 'never'
// 原因: 型定義が不足
// 解決策: 明示的に型を定義
const [todos, setTodos] = useState<Todo[]>([]); // 型を明示
エラー3: CSS Modulesが適用されない
// 間違い
import './TodoList.module.css'; // ❌
// 正しい
import styles from './TodoList.module.css'; // ✅
📊 パフォーマンスの確認
Chrome DevToolsのNetworkタブで確認:
- 初期ロード時間
- バンドルサイズ
- API通信の速度
💾 作業をコミット
🔨 実際にやってみましょう!
# 変更を確認
git status
# 追加されたファイルを確認
git diff --name-only
# すべての変更をステージング
git add .
# コミット
git commit -m "React + TypeScript統合: 基本的なTodo UI実装"
変更内容の確認:
- ✅ TypeScript設定ファイル
- ✅ React関連パッケージ
- ✅ Todoコンポーネント群
- ✅ CSS Modules
- ✅ ERB統合
📋 まとめ
✅ この章で達成したこと
第5章お疲れさまでした!以下のことができるようになりました:
- 📘 TypeScriptの基礎: 型注釈、インターフェース、型推論
- 🛠️ 開発環境構築: VSCode + ESLint + Prettier
- ⚛️ React統合: react_on_rails + rewrap
- 🎨 UI実装: TodoList、TodoItem、AddTodoForm
- 🔧 CSS Modules: コンポーネント単位のスタイリング
🚨 現在の制限事項
- 認証が無効化されているため、user_id=1を固定で使用
- エラーハンドリングが最小限
- UIがシンプル(第6章で改善)
🎯 次章で学ぶこと
第6章では、以下を実装します:
- 🔐 認証機能の統合: ログイン・ログアウト機能
- 📝 実践的なTypeScript: APIレスポンスの型定義、カスタムフック
- 🎨 リッチなUI: ローディング、エラー表示、アニメーション
- 🔄 状態管理: Context APIによるグローバル状態管理
🏃 演習問題
📝 演習1: TypeScriptの型定義を強化
現在user_id=1を固定で使っていますが、これを安全に管理する型を作成してください。
💡 ヒント(クリックで展開)
// constants/user.ts
export const TEMP_USER_ID = 1 as const;
// utils/api.ts
const buildUrl = (path: string): string => {
return `/api/v1${path}?user_id=${TEMP_USER_ID}`;
};
// components/TodoList.tsx
import { TEMP_USER_ID } from '@/constants/user';
// 直接文字列ではなく定数を使用
📝 演習2: ローディング中のスケルトンUI
Todoリストがローディング中にスケルトン(プレースホルダー)を表示する機能を追加してください。
💡 ヒント(クリックで展開)
// components/TodoSkeleton.tsx
const TodoSkeleton: React.FC = () => {
return (
<div className={styles.skeleton}>
<div className={styles.skeletonCheckbox} />
<div className={styles.skeletonContent}>
<div className={styles.skeletonTitle} />
<div className={styles.skeletonDescription} />
</div>
<div className={styles.skeletonButton} />
</div>
);
};
// TodoListで使用
{loading ? (
<>
<TodoSkeleton />
<TodoSkeleton />
<TodoSkeleton />
</>
) : (
// 通常のTodoリスト
)}
/* TodoSkeleton.module.css */
@keyframes shimmer {
0% { background-position: -200px 0; }
100% { background-position: calc(200px + 100%) 0; }
}
.skeleton {
/* グラデーションアニメーション */
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200px 100%;
animation: shimmer 1.5s infinite;
}
📝 演習3: キーボードショートカット
Todoアプリにキーボードショートカットを追加してください。
Ctrl/Cmd + N: 新しいTodoを追加Ctrl/Cmd + /: フォーカスを検索ボックスに移動
💡 ヒント(クリックで展開)
// hooks/useKeyboardShortcuts.ts
export const useKeyboardShortcuts = () => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl/Cmd + N
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
e.preventDefault();
// 新規Todo追加フォームにフォーカス
document.getElementById('todo-input')?.focus();
}
// Ctrl/Cmd + /
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
// 検索ボックスにフォーカス
document.getElementById('search-input')?.focus();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
};
📝 演習4: エラーバウンダリの実装
コンポーネントでエラーが発生した時に、アプリ全体がクラッシュしないようにエラーバウンダリを実装してください。
💡 ヒント(クリックで展開)
// components/ErrorBoundary.tsx
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
ErrorBoundaryState
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('エラー詳細:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="error-boundary">
<h2>エラーが発生しました</h2>
<details>
<summary>詳細を表示</summary>
<pre>{this.state.error?.stack}</pre>
</details>
<button onClick={() => window.location.reload()}>
ページを再読み込み
</button>
</div>
);
}
return this.props.children;
}
}
// 使用例
<ErrorBoundary>
<TodoList />
</ErrorBoundary>
📚 参考資料
より深く学びたい方向け:
次章: 第6章: 認証統合とリッチUI →