Skip to main content

第5章: React + TypeScript入門と基礎統合

🎯 この章で学ぶこと(所要時間: 3.5時間)

TypeScriptの基礎を学び、Rails環境にReactを統合します。react_on_railsとrewrapを使って、シンプルなTodoアプリのUIを構築します。

📢 はじめに

第4章までで、セキュアなRails APIが完成しました。いよいよフロントエンドの構築に入ります。

今回は、TypeScriptという「型のあるJavaScript」を使います。TypeScriptを使うと、コードを書いている時点でエラーに気づけるので、バグが減り、開発効率が上がります。

また、RailsとReactを組み合わせる方法として、react_on_railsrewrapを使います。これにより、Railsの良さを活かしながら、モダンなフロントエンドを構築できます。

🎓 この章を終えると...

  • TypeScriptの基本的な型が理解できる
  • VSCodeで快適な開発環境が構築できる
  • RailsにReactを統合する方法がわかる
  • シンプルなTodoアプリのUIが作れる
  • CSS Modulesでスタイリングができる

💡 この章で身につくスキル

  • 📘 TypeScriptの基本(型注釈、インターフェース)
  • 🛠️ VSCode + ESLint + Prettierの設定
  • ⚛️ React基本コンポーネントの作成
  • 🔧 react_on_railsとrewrapの活用
  • 🎨 CSS Modulesによるスタイリング
  • 🌐 APIとの基本的な通信
📖 この章の進め方
  1. セクション0: 作業ブランチの作成(実践)
  2. セクション1: 開発環境の整備(実践)
  3. セクション2: TypeScript入門(読みながら実践)
  4. セクション3: React + Rails統合(実践)
  5. セクション4: 基本UIの実装(実践)
  6. セクション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を開いて、拡張機能タブ(左サイドバーの四角いアイコン)から以下を検索してインストール:

  1. ESLint - コードの問題を検出
  2. Prettier - Code formatter - コードを自動整形
  3. TypeScript React code snippets - React開発を効率化
  4. Ruby - Rubyファイルのシンタックスハイライト
  5. Simple Ruby ERB - ERBファイルのサポート

📝 プロジェクト設定ファイルの作成

🔨 実際にやってみましょう!

プロジェクトのルートディレクトリに.vscodeフォルダを作成し、設定ファイルを追加します:

ターミナルで実行
# .vscodeディレクトリを作成
mkdir -p .vscode
.vscode/settings.json
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"typescript.preferences.importModuleSpecifier": "relative",
"files.associations": {
"*.css": "css"
}
}
.prettierrc
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false
}
.prettierignore
# 無視するファイル/ディレクトリ
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つのメリット

  1. 開発中にエラーを発見

    • タイポや型の不一致をVSCodeがリアルタイムで指摘
    • 「undefined」エラーを事前に防げる
  2. エディタの補完が賢くなる

    • プロパティ名を自動補完
    • メソッドの引数を教えてくれる
    • ドキュメントを見る回数が減る
  3. リファクタリングが安全

    • 変数名の変更が漏れなく反映
    • 不要なコードを安全に削除
    • 大規模な変更も自信を持って実行

🎯 基本的な型

🔨 実際にやってみましょう!

以下の内容でplayground.tsファイルを作成して、TypeScriptを体験してみましょう:

ターミナルで実行
# playgroundファイルを作成
touch playground.ts

# VSCodeで開く
code playground.ts
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を体験

🔨 実際にやってみましょう!

  1. VSCodeでplayground.tsを開く
  2. エラーがある行に赤い波線が表示される
  3. 変数にカーソルを合わせると型情報が表示される
  4. 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を統合する方法はいくつかありますが、今回はこの組み合わせを選びました:

  1. react_on_rails: Rails環境でReactを使うための定番gem

    • サーバーサイドレンダリング(SSR)対応
    • Rails資産パイプラインとの統合
    • プロダクション環境での実績多数
  2. rewrap: ReactコンポーネントをWeb Componentsに変換

    • ERBテンプレート内で<todo-list></todo-list>のように使える
    • 段階的な移行が可能(一部だけReact化)
    • RailsのTurboとの相性が良い

🏗️ アーキテクチャの全体像

🚀 react_on_railsのセットアップ

🔨 実際にやってみましょう!

Gemfileに追加:

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設定の更新

🔨 実際にやってみましょう!

vite.config.ts
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',
},
},
});

🎯 エントリーポイントの作成

🔨 実際にやってみましょう!

app/frontend/entrypoints/frontend.ts
// 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}

📝 型定義の作成

🔨 実際にやってみましょう!

app/frontend/types/todo.ts
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コンポーネント

🔨 実際にやってみましょう!

app/frontend/components/TodoList.tsx
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コンポーネント

🔨 実際にやってみましょう!

app/frontend/components/TodoItem.tsx
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コンポーネント

🔨 実際にやってみましょう!

app/frontend/components/AddTodoForm.tsx
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>

🔨 実際にやってみましょう!

app/frontend/components/TodoList.module.css
.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;
}
app/frontend/components/TodoItem.module.css
.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;
}
app/frontend/components/AddTodoForm.module.css
.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の役割分担

要素担当理由
ページレイアウトERBSEO、初期表示速度
動的UIReactインタラクティブ性
ルーティングRailsURLベースのページ遷移
状態管理ReactUIの状態変化

🔐 認証の一時無効化

開発を簡単にするため、一時的に認証を無効にします。

🔨 実際にやってみましょう!

app/controllers/application_controller.rb
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
⚠️ 本番環境では絶対にNG!

認証の無効化は開発時のみの措置です。本番環境では必ず有効化してください。 第6章で認証を再度有効化します。

🏠 ホームコントローラーの作成

🔨 実際にやってみましょう!

ターミナルで実行
docker compose exec web rails generate controller home index
app/controllers/home_controller.rb
class HomeController < ApplicationController
def index
# ERBテンプレートをレンダリング
end
end

🛤️ ルーティングの設定

🔨 実際にやってみましょう!

config/routes.rb
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テンプレートの作成

🔨 実際にやってみましょう!

app/views/layouts/application.html.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>
app/views/home/index.html.erb
<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も返せるように修正:

🔨 実際にやってみましょう!

app/controllers/application_controller.rb
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モードで動作:

app/controllers/api/v1/base_controller.rb
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コントローラーを修正:

app/controllers/api/v1/todos_controller.rb
module Api
module V1
class TodosController < BaseController # ApplicationControllerからBaseControllerに変更
# ... 既存のコード ...
end
end
end

6. 動作確認とデバッグ

🐛 開発ツールの活用

Chrome DevToolsでReactをデバッグ

React Developer Toolsをインストール:

  1. Chrome拡張機能ストアで「React Developer Tools」を検索
  2. インストール後、DevToolsに「Components」タブが追加される

使い方:

// コンポーネントの状態を確認
// 1. ComponentsタブでTodoListを選択
// 2. 右側のパネルでstate、propsを確認
// 3. 値を直接編集して動作確認も可能

VSCodeでのデバッグ設定

🔨 実際にやってみましょう!

.vscode/launch.jsonを作成:

.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

🌐 ブラウザで確認

  1. http://localhost:3000 にアクセス
  2. 「Todoアプリケーション」というタイトルが表示される
  3. TodoListコンポーネントが表示される
  4. Todoの追加・完了・削除ができることを確認

🧪 動作テスト

以下の操作を試してみましょう:

  1. Todo追加: 「買い物に行く」と入力して追加ボタンをクリック
  2. 完了状態の切り替え: チェックボックスをクリック
  3. 削除: ×ボタンをクリック

🔍 よくあるエラーと対処法

エラー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