Skip to main content

第6章: 認証統合とリッチUI

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

実践的なTypeScriptの型定義を学び、認証機能を統合します。Context APIを使った状態管理とChakra UIによるモダンなUIで、プロダクションレベルのTodoアプリケーションを完成させます。

なぜChakra UIを選ぶのか?

Chakra UIは以下の理由から初学者に最適です:

  • 🎨 直感的なAPI: 分かりやすいプロパティ名とコンポーネント構造
  • 📦 豊富なコンポーネント: 必要なUIパーツがすべて揃っている
  • アクセシビリティ: デフォルトでWCAG準拠
  • 🌙 ダークモード: 組み込みのテーマ切り替え機能
  • 📱 レスポンシブ: モバイルファーストの設計

📢 はじめに

第5章では、TypeScriptの基礎とReact統合を学びました。基本的なTodo UIは動作していますが、認証がなく、UIもシンプルです。

この章では、第4章で作った認証APIと連携し、ログイン機能を実装します。また、実践的なTypeScriptの型定義を学び、エラーハンドリングやローディング表示など、実際のアプリケーションに必要な機能を追加します。

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

  • APIレスポンスの型定義ができる
  • カスタムフックが作成できる
  • Context APIで状態管理ができる
  • エラーハンドリングが実装できる
  • プロダクションレベルのUIが作れる
  • 完全なフルスタックアプリが完成する

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

  • 📝 実践的なTypeScript型定義
  • 🪝 カスタムフックの作成
  • 🔐 認証フローの実装
  • 🌐 Context APIによる状態管理
  • ⚠️ エラーハンドリング
  • 🎨 Chakra UIによるモダンなUI構築
  • 🎭 ローディング状態とトースト通知
  • 🔧 フォームバリデーション
📖 この章の進め方
  1. セクション0: 作業ブランチの作成(実践)
  2. セクション1: 実践的なTypeScript(学習)
  3. セクション2: 認証関連の型定義(実践)
  4. セクション3: 認証Context実装(実践)
  5. セクション4: ログイン・サインアップUI(実践)
  6. セクション5: Todo機能の認証対応(実践)
  7. セクション6: UI/UXの改善(実践)
  8. セクション7: 動作確認(実践)
📓 前提条件
  • 第5章の実装が完了していること
  • 認証が一時的に無効化されていること
  • TypeScriptの基本を理解していること

それでは、本格的なWebアプリケーションを完成させましょう!

📚 0. 作業ブランチの作成

新しい機能開発なので、新しいブランチを作成します。

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

ターミナルで実行
# 現在のブランチを確認
git branch

# feature/chapter-5の変更をコミット
git add .
git commit -m "第5章完了"

# mainブランチに切り替え
git checkout main

# feature/chapter-5をマージ
git merge feature/chapter-5

# feature/chapter-6ブランチを作成して切り替え
git checkout -b feature/chapter-6

1. 認証の再有効化

🔐 なぜ認証を再有効化するのか?

第5章では、UIの基盤構築に集中するため、一時的に認証を無効化しました。これにより:

  • 認証を気にせずにUI開発に集中できた
  • TypeScriptやReactの学習に専念できた
  • エラーの原因が明確になった

しかし、実際のアプリケーションでは認証は必須です。今回は第4章で作ったCookieベース認証をReactと統合します。

APIレスポンスの型定義

app/frontend/types/api.ts
// ジェネリクス<T>を使った汎用的なレスポンス型
export interface ApiResponse<T> {
data?: T;
error?: string;
message?: string;
}

// エラーレスポンス
export interface ApiError {
error: string;
details?: Record<string, string[]>;
}

// ページネーション付きレスポンス
export interface PaginatedResponse<T> {
data: T[];
meta: {
current_page: number;
per_page: number;
total_count: number;
};
}

ユーザー・認証関連の型

app/frontend/types/auth.ts
export interface User {
id: number;
email: string;
name: string;
}

export interface LoginFormData {
email: string;
password: string;
}

export interface SignupFormData extends LoginFormData {
name: string;
password_confirmation: string;
}

export interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}

関数の型定義

// 非同期関数の型
type AsyncFunction<T> = () => Promise<T>;

// イベントハンドラーの型
type FormSubmitHandler = (e: React.FormEvent<HTMLFormElement>) => void;
type InputChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => void;

// カスタムフックの戻り値
interface UseAuthReturn {
user: User | null;
login: (data: LoginFormData) => Promise<void>;
logout: () => Promise<void>;
signup: (data: SignupFormData) => Promise<void>;
isLoading: boolean;
error: string | null;
}

🎯 型定義のチートシート

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

プロジェクトで使う型定義をまとめたファイルを作成:

app/frontend/types/index.ts
// 基本的な型のエクスポート
export * from './todo';
export * from './auth';
export * from './api';

// よく使うユーティリティ型
export type Nullable<T> = T | null;
export type Optional<T> = T | undefined;
export type ValueOf<T> = T[keyof T];

// ReactでよくOK型
export type SetState<T> = React.Dispatch<React.SetStateAction<T>>;
export type FC<P = {}> = React.FC<P>;

🔐 ApplicationControllerの修正

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

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include ActionController::Cookies

protect_from_forgery with: :exception
before_action :require_authentication # 認証を有効化

# 認証不要なアクション
skip_before_action :require_authentication, only: [:health]

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
respond_to do |format|
format.html { redirect_to login_path, alert: 'ログインが必要です' }
format.json { render json: { error: '認証が必要です' }, status: :unauthorized }
end
end
end
end

🏠 HomeControllerの修正

ログインページとアプリケーションページを分けるため、HomeControllerを修正:

app/controllers/home_controller.rb
class HomeController < ApplicationController
skip_before_action :require_authentication, only: [:index]

def index
# ログイン済みならダッシュボード、未ログインならランディングページ
@current_user = current_user
end
end

2. Chakra UIの導入

🎨 なぜChakra UIを使うのか?

第5章ではCSS Modulesを使いましたが、今回はChakra UIを導入します。

Chakra UIのメリット

  • 🎯 直感的なAPI: 分かりやすいコンポーネント名とprops
  • 📦 豊富なコンポーネント: 必要なUI部品がすべて揃っている
  • アクセシビリティ: WAI-ARIAガイドラインに準拠
  • 🌙 ダークモード: 組み込みのテーマ切り替え機能
  • 📱 レスポンシブ: モバイルファーストの設計
  • 🎨 カスタマイズ性: テーマによる統一的なデザインシステム
  • 💻 TypeScriptサポート: 完全な型定義付き
なぜTailwindCSSではなくChakra UIなのか?

TailwindCSSは非常に人気のあるユーティリティファーストのCSSフレームワークですが、初学者にとっては以下の理由でChakra UIの方が適しています:

  • 学習曲線: Chakra UIはコンポーネントベースで、Reactの考え方と一致
  • 記述量: TailwindCSSはクラス名が長くなりがちで、初心者には読みづらい
  • 設定: Chakra UIは追加設定なしですぐに使える
  • 一貫性: デザインシステムが最初から整っている

TailwindCSSは素晴らしいツールですが、より高度なカスタマイズが必要になったときに検討することをお勧めします。

🔨 Chakra UIのセットアップ

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

ターミナルで実行
# Chakra UIと必要なパッケージをインストール
docker compose exec web npm install @chakra-ui/react @emotion/react @emotion/styled framer-motion

# アイコンパッケージもインストール(オプション)
docker compose exec web npm install @chakra-ui/icons

⚙️ Chakra UIの設定

Chakra UIはProviderで全体をラップする必要があります。これにより、テーマやカラーモードなどの設定が全コンポーネントで利用可能になります。

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

app/frontend/theme/index.ts
import { extendTheme, type ThemeConfig } from '@chakra-ui/react';

// カラーモードの設定
const config: ThemeConfig = {
initialColorMode: 'light',
useSystemColorMode: false,
};

// カスタムテーマの定義
export const theme = extendTheme({
config,
colors: {
// ブランドカラーの定義
brand: {
50: '#E3F2FD',
100: '#BBDEFB',
200: '#90CAF9',
300: '#64B5F6',
400: '#42A5F5',
500: '#2196F3', // メインカラー
600: '#1E88E5',
700: '#1976D2',
800: '#1565C0',
900: '#0D47A1',
},
},
fonts: {
heading: '"Noto Sans JP", sans-serif',
body: '"Noto Sans JP", sans-serif',
},
styles: {
global: {
body: {
bg: 'gray.50',
color: 'gray.800',
},
},
},
components: {
// コンポーネントのデフォルトスタイルをカスタマイズ
Button: {
defaultProps: {
colorScheme: 'brand',
},
},
Input: {
defaultProps: {
focusBorderColor: 'brand.500',
},
},
},
});

🌐 ChakraProviderの設定

アプリケーション全体をChakraProviderでラップします。

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

app/frontend/components/App.tsx
import React from 'react';
import { ChakraProvider } from '@chakra-ui/react';
import { theme } from '@/theme';
import AuthProvider from '@/contexts/AuthContext';
import TodoApp from './TodoApp';

const App: React.FC = () => {
return (
<ChakraProvider theme={theme}>
<AuthProvider>
<TodoApp />
</AuthProvider>
</ChakraProvider>
);
};

export default App;

📚 Chakra UIの基本的な使い方

Chakra UIはコンポーネントベースで、propsでスタイルを制御します:

import { Box, Button, Text, VStack, HStack } from '@chakra-ui/react';

// 基本的な使い方
<Button colorScheme="blue" size="md">
クリック
</Button>

// レスポンシブデザイン
<Box
w={{ base: "100%", sm: "auto", md: "50%", lg: "33%" }}
p={{ base: 4, md: 6, lg: 8 }}
>
レスポンシブなボックス
</Box>

// フレックスボックスとスペーシング
<VStack spacing={4} align="stretch">
<Text fontSize="lg" fontWeight="bold">タイトル</Text>
<HStack spacing={2}>
<Button size="sm">編集</Button>
<Button size="sm" colorScheme="red">削除</Button>
</HStack>
</VStack>

// ダークモード対応(自動)
<Box bg="white" _dark={{ bg: "gray.800" }}>
自動的にダークモード対応
</Box>

🔍 VSCode拡張機能

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

VSCodeでChakra UIの開発を効率化する拡張機能:

  1. Chakra UI Snippets: Chakra UIコンポーネントのスニペット
  2. IntelliSense for CSS-in-JS: Emotion/styled-componentsの補完

これらにより:

  • コンポーネントの自動補完
  • propsの型情報表示
  • カラースキームのプレビュー

3. UIライブラリの比較

📊 React UIライブラリの選択肢

モダンなReactアプリケーションを構築する際、UIライブラリの選択は重要です。代表的なライブラリを比較してみましょう。

実際の表示例

以下では、各UIライブラリのボタンスタイルを実際に表示しています。それぞれのライブラリの特徴的なデザインを視覚的に確認できます。

1. Chakra UI(今回使用)

import { Button, Input, Modal } from '@chakra-ui/react';

<Button colorScheme="blue">
クリック
</Button>
💻 実装例を見る(クリックで展開)

Chakra UI 風のボタン例(モック)

Solid Button:

Outline Button:

Ghost Button:

// Chakra UIのボタンコンポーネント例
import { Button, VStack } from '@chakra-ui/react';

export const ButtonExamples = () => {
return (
<VStack spacing={4}>
<Button colorScheme="blue" variant="solid">
Solid Button
</Button>
<Button colorScheme="blue" variant="outline">
Outline Button
</Button>
<Button colorScheme="blue" variant="ghost">
Ghost Button
</Button>
<Button colorScheme="blue" variant="link">
Link Button
</Button>
<Button
colorScheme="blue"
isLoading
loadingText="送信中"
>
Loading Button
</Button>
</VStack>
);
};

メリット:

  • 🎨 モダンでシンプル
  • ✨ アクセシビリティ重視
  • 📦 軽量
  • 🎆 TypeScriptサポート完備
  • 🌙 ダークモード内蔵
  • 🎯 Reactのベストプラクティスに沿った設計

デメリット:

  • 🎨 デザインがシンプルすぎる場合がある
  • 📦 コンポーネント数が少ない

2. Mantine

import { Button, TextInput, Modal } from '@mantine/core';

<Button variant="filled" color="blue">
クリック
</Button>

Mantine 風のボタン例(モック)

Filled Button:

Light Button:

Outline Button:

メリット:

  • 🎆 100+のコンポーネント
  • 🎨 カスタマイズが簡単
  • 📦 フックやユーティリティも同梱
  • 🌙 ダークモード内蔵
  • 📑 TypeScriptファースト

デメリット:

  • 📦 バンドルサイズが大きい
  • 🎨 デザインが独特

3. Material-UI (MUI)

import { Button, TextField, Dialog } from '@mui/material';

<Button variant="contained" color="primary">
クリック
</Button>

Material-UI 風のボタン例(モック)

Contained Button:

Text Button:

Outlined Button:

メリット:

  • 🎯 GoogleのMaterial Design準拠
  • 📦 非常に充実したコンポーネント
  • 🌐 大規模なコミュニティ
  • 📄 詳細なドキュメント

デメリット:

  • 🏢 エンタープライズ向け
  • 📦 非常に大きい
  • 🎨 カスタマイズが難しい

4. TailwindCSS(ユーティリティファースト)

// UIライブラリではなく、ユーティリティクラスで直接スタイリング
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
クリック
</button>
TailwindCSSについて

TailwindCSSは2024年現在、最も人気のあるCSSフレームワークの一つとなっています。

採用事例:

  • 🏢 Vercel: Next.jsの開発元
  • 🚀 GitHub: Copilotのドキュメント
  • 🌎 Netflix: 一部のプロジェクトで採用
  • 📚 Tailwind UI: 公式の有料コンポーネント集

メリット:

  • 🚀 非常に高速な開発
  • 📦 最終的なバンドルサイズが小さい
  • 🎨 完全なカスタマイズ性
  • 📊 CSS-in-JSと比べてパフォーマンスが高い

デメリット:

  • 🧩 コンポーネントを自作する必要がある
  • 📝 クラス名が長くなりがち
  • 🎓 初学者には学習コストが高い
  • 🔄 HTMLとスタイルが混在し、可読性が下がる

本チュートリアルではChakra UIを使用します。初学者にとってはコンポーネントベースの方が理解しやすく、TypeScriptの型サポートも充実しているためです。

しかし、将来的にはより細かいデザイン調整が必要になる場面もあるでしょう。その際はTailwindCSSの学習を検討してください。

🤔 どれを選ぶべきか?

ライブラリこんな時におすすめ
Chakra UIシンプルでモダン、初学者フレンドリー
Mantineモダンで機能的なUI、TypeScriptプロジェクト
MUIMaterial Designに従いたい、大規模アプリ
TailwindCSS完全なカスタマイズ、独自デザイン

🎯 今回の選択: Chakra UI

今回はChakra UIを選択しました。理由:

  • 🎓 初学者に優しい: コンポーネントベースで理解しやすい
  • 📦 必要なものが揃っている: 追加設定なしですぐ使える
  • アクセシビリティ標準対応: プロダクション品質
  • 🌙 ダークモード内蔵: モダンなUIが簡単に
  • 📁 TypeScript完全サポート: propsの型がしっかりしている
  • 🎨 統一されたデザインシステム: 一貫性のあるUIが作れる

4. 実践的なTypeScript型定義

📝 認証関連の型定義

第5章では基本的な型を学びました。今回は認証機能を実装するために、より実践的な型定義を学びます。

認証機能では、ユーザー情報、ログインフォーム、サインアップフォームなど、様々なデータ構造を扱います。これらを適切に型定義することで、バグを防ぎ、開発効率を向上させることができます。

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

app/frontend/types/auth.ts
// ユーザー情報の型
export interface User {
id: number;
name: string;
email: string;
created_at: string;
updated_at: string;
}

// ログインフォームの型
export interface LoginFormData {
email: string;
password: string;
}

// サインアップフォームの型
export interface SignupFormData {
name: string;
email: string;
password: string;
password_confirmation: string;
}

// 認証状態の型
export interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}

// APIエラーレスポンスの型
export interface ApiError {
error?: string;
errors?: string[];
}

これらの型定義により、以下のメリットが得られます:

  • 型安全性: コンパイル時にプロパティ名のミスを検出
  • 自動補完: VSCodeでプロパティが自動補完される
  • ドキュメント代わり: 型定義がそのままドキュメントになる

🌐 認証状態管理の必要性

認証機能を実装する際、以下の課題に直面します:

  1. 状態の共有: ログイン状態を複数のコンポーネントで共有したい
  2. 状態の一元管理: ユーザー情報を一箇所で管理したい
  3. ロジックの共通化: ログイン、ログアウトの処理を共通化したい

これらの課題を解決するために、ReactのContext APIを使用します。Context APIを使うことで、グローバルな状態管理が可能になり、propsのバケツリレー(prop drilling)を回避できます。

app/frontend/contexts/AuthContext.tsx
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { User, LoginFormData, SignupFormData, AuthState } from '@/types';

interface AuthContextType extends AuthState {
login: (data: LoginFormData) => Promise<void>;
logout: () => Promise<void>;
signup: (data: SignupFormData) => Promise<void>;
checkAuth: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

interface AuthProviderProps {
children: ReactNode;
}

export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [state, setState] = useState<AuthState>({
user: null,
isAuthenticated: false,
isLoading: true,
error: null,
});

// 現在のユーザー情報を取得
const checkAuth = async () => {
try {
const response = await fetch('/api/v1/me', {
credentials: 'include',
});

if (response.ok) {
const data = await response.json();
setState({
user: data.user,
isAuthenticated: true,
isLoading: false,
error: null,
});
} else {
setState({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
});
}
} catch (error) {
setState({
user: null,
isAuthenticated: false,
isLoading: false,
error: 'ネットワークエラーが発生しました',
});
}
};

// ログイン
const login = async (data: LoginFormData) => {
setState(prev => ({ ...prev, isLoading: true, error: null }));

try {
const response = await fetch('/api/v1/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
credentials: 'include',
});

if (response.ok) {
const result = await response.json();
setState({
user: result.user,
isAuthenticated: true,
isLoading: false,
error: null,
});
} else {
const error = await response.json();
setState({
user: null,
isAuthenticated: false,
isLoading: false,
error: error.error || 'ログインに失敗しました',
});
}
} catch (error) {
setState({
user: null,
isAuthenticated: false,
isLoading: false,
error: 'ネットワークエラーが発生しました',
});
}
};

// ログアウト
const logout = async () => {
setState(prev => ({ ...prev, isLoading: true }));

try {
await fetch('/api/v1/logout', {
method: 'DELETE',
credentials: 'include',
});

setState({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
});
} catch (error) {
setState(prev => ({
...prev,
isLoading: false,
error: 'ログアウトに失敗しました',
}));
}
};

// サインアップ
const signup = async (data: SignupFormData) => {
setState(prev => ({ ...prev, isLoading: true, error: null }));

try {
// まずユーザーを作成
const createResponse = await fetch('/api/v1/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user: data }),
});

if (!createResponse.ok) {
const error = await createResponse.json();
throw new Error(error.errors?.join(', ') || 'サインアップに失敗しました');
}

// 作成成功したらログイン
await login({ email: data.email, password: data.password });
} catch (error) {
setState({
user: null,
isAuthenticated: false,
isLoading: false,
error: error instanceof Error ? error.message : 'サインアップに失敗しました',
});
}
};

// 初回マウント時に認証状態をチェック
useEffect(() => {
checkAuth();
}, []);

const value: AuthContextType = {
...state,
login,
logout,
signup,
checkAuth,
};

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

5. 認証Contextの活用方法

上記で作成したAuthContextを実際に使用する方法を見ていきましょう。

🎆 アプリケーション全体への統合

AuthProviderをアプリケーションのルートに配置することで、どのコンポーネントからでも認証状態にアクセスできるようになります。

app/frontend/components/App.tsx
import React from 'react';
import { ChakraProvider } from '@chakra-ui/react';
import { theme } from '@/theme';
import { AuthProvider } from '@/contexts/AuthContext';
import TodoApp from './TodoApp';

const App: React.FC = () => {
return (
<ChakraProvider theme={theme}>
<AuthProvider>
<TodoApp />
</AuthProvider>
</ChakraProvider>
);
};

export default App;

🎯 useAuthフックの使用例

コンポーネント内でuseAuthフックを使って認証情報にアクセスする例:

// 任意のコンポーネント内で
const { user, isAuthenticated, login, logout } = useAuth();

// 認証状態に応じた表示切り替え
if (isAuthenticated) {
return <div>ようこそ、{user?.name}さん!</div>;
} else {
return <LoginForm />;
}

6. ログイン・サインアップUIの実装

認証状態管理の準備が整ったので、実際のログイン・サインアップ画面を作成していきます。

🔐 Chakra UIを使ったログインフォーム

ここからはChakra UIを使って、美しくモダンなUIを構築していきます。Chakra UIはpropでスタイルを指定できるため、クラス名を覚える必要がなく、TypeScriptの型サポートも完璧です。

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

app/frontend/components/auth/LoginForm.tsx
import React, { useState } from 'react';
import {
Box,
VStack,
Heading,
Text,
FormControl,
FormLabel,
Input,
Button,
Alert,
AlertIcon,
AlertDescription,
Link,
Container,
useColorModeValue,
InputGroup,
InputRightElement,
IconButton,
} from '@chakra-ui/react';
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons';
import { LoginFormData } from '@/types';
import { useAuth } from '@/contexts/AuthContext';

interface LoginFormProps {
onSuccess?: () => void;
onSignupClick?: () => void;
}

const LoginForm: React.FC<LoginFormProps> = ({ onSuccess, onSignupClick }) => {
const { login, isLoading, error } = useAuth();
const [formData, setFormData] = useState<LoginFormData>({
email: '',
password: '',
});
const [showPassword, setShowPassword] = useState(false);

// カラーモード対応
const bg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await login(formData);
if (onSuccess) {
onSuccess();
}
};

return (
<Container maxW="lg" py={{ base: '12', md: '24' }} px={{ base: '4', sm: '8' }}>
<VStack spacing="8">
<VStack spacing="6" textAlign="center">
<Heading size="xl">アカウントにログイン</Heading>
<Text color="gray.600">Todoアプリを使い始めましょう</Text>
</VStack>

<Box
py={{ base: '8', sm: '8' }}
px={{ base: '4', sm: '10' }}
bg={bg}
boxShadow={{ base: 'none', sm: 'md' }}
borderRadius={{ base: 'none', sm: 'xl' }}
borderWidth="1px"
borderColor={borderColor}
w="full"
maxW="md"
>
<form onSubmit={handleSubmit}>
<VStack spacing="6">
{error && (
<Alert status="error" borderRadius="md">
<AlertIcon />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}

<FormControl isRequired>
<FormLabel>メールアドレス</FormLabel>
<Input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="example@email.com"
size="lg"
/>
</FormControl>

<FormControl isRequired>
<FormLabel>パスワード</FormLabel>
<InputGroup size="lg">
<Input
name="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleChange}
placeholder="••••••••"
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? 'パスワードを隠す' : 'パスワードを表示'}
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
variant="ghost"
size="sm"
/>
</InputRightElement>
</InputGroup>
</FormControl>

<Button
type="submit"
colorScheme="brand"
size="lg"
w="full"
isLoading={isLoading}
loadingText="ログイン中..."
>
ログイン
</Button>

<Text fontSize="sm" textAlign="center">
アカウントをお持ちでない方は{' '}
<Link
color="brand.500"
onClick={onSignupClick}
cursor="pointer"
textDecoration="underline"
>
サインアップ
</Link>
</Text>
</VStack>
</form>
</Box>
</VStack>
</Container>
);
};

export default LoginForm;

🎨 Chakra UIのコンポーネントパターン

上記のLoginFormで使用したChakra UIの特徴を解説します:

📐 レスポンシブデザイン

// レスポンシブなprop
<Container
maxW="lg"
py={{ base: '12', md: '24' }} // モバイルとPCで異なるパディング
px={{ base: '4', sm: '8' }}
>

// ブレークポイントの種類
// base: 0px〜
// sm: 480px〜
// md: 768px〜
// lg: 992px〜
// xl: 1280px〜

🌙 ダークモード対応

// useColorModeValueでカラーモードに応じた色を設定
const bg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');

// 使用例
<Box bg={bg} borderColor={borderColor}>
自動的にダークモード対応
</Box>

🎭 アニメーションとインタラクション

// ローディング状態の表示
<Button
isLoading={isLoading}
loadingText="ログイン中..."
spinner={<Spinner />} // カスタムスピナーも可能
>

// パスワードの表示/非表示切り替え
<InputGroup>
<Input type={showPassword ? 'text' : 'password'} />
<InputRightElement>
<IconButton
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
/>
</InputRightElement>
</InputGroup>

🔧 TypeScriptサポート

// すべてのコンポーネントが型定義済み
import { Button, ButtonProps } from '@chakra-ui/react';

// propの型が自動補完される
<Button
colorScheme="brand" // 型チェックされる
size="lg" // 'sm' | 'md' | 'lg' | 'xs'
variant="solid" // 'solid' | 'outline' | 'ghost' | 'link'
/>

📝 SignupFormコンポーネント

サインアップフォームもChakra UIを使って実装します。LoginFormと同様に、フォームバリデーションとエラーハンドリングを含む完全な機能を提供します。

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

app/frontend/components/auth/SignupForm.tsx
import React, { useState } from 'react';
import {
Box,
VStack,
Heading,
Text,
FormControl,
FormLabel,
Input,
FormErrorMessage,
Button,
Alert,
AlertIcon,
AlertDescription,
Link,
Center,
Container,
useColorModeValue,
InputGroup,
InputRightElement,
IconButton,
} from '@chakra-ui/react';
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons';
import { SignupFormData } from '@/types';
import { useAuth } from '@/contexts/AuthContext';

interface SignupFormProps {
onSuccess?: () => void;
onLoginClick?: () => void;
}

const SignupForm: React.FC<SignupFormProps> = ({ onSuccess, onLoginClick }) => {
const { signup, isLoading, error } = useAuth();
const [showPassword, setShowPassword] = useState(false);
const [showPasswordConfirmation, setShowPasswordConfirmation] = useState(false);
const [formData, setFormData] = useState<SignupFormData>({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const [validationErrors, setValidationErrors] = useState<Partial<SignupFormData>>({});

const bgColor = useColorModeValue('gray.50', 'gray.800');
const cardBg = useColorModeValue('white', 'gray.700');

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
// フィールドの値が変更されたらエラーをクリア
if (validationErrors[name as keyof SignupFormData]) {
setValidationErrors(prev => ({ ...prev, [name]: undefined }));
}
};

const validate = (): boolean => {
const errors: Partial<SignupFormData> = {};

if (!formData.name) {
errors.name = '名前を入力してください';
}

if (!formData.email) {
errors.email = 'メールアドレスを入力してください';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
errors.email = '有効なメールアドレスを入力してください';
}

if (!formData.password) {
errors.password = 'パスワードを入力してください';
} else if (formData.password.length < 6) {
errors.password = 'パスワードは6文字以上で入力してください';
}

if (!formData.password_confirmation) {
errors.password_confirmation = 'パスワード(確認)を入力してください';
} else if (formData.password !== formData.password_confirmation) {
errors.password_confirmation = 'パスワードが一致しません';
}

setValidationErrors(errors);
return Object.keys(errors).length === 0;
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

if (!validate()) {
return;
}

await signup(formData);
if (onSuccess) {
onSuccess();
}
};

return (
<Box minH="100vh" bg={bgColor} py={12} px={4}>
<Container maxW="md">
<Box bg={cardBg} p={8} borderRadius="lg" boxShadow="lg">
<VStack spacing={8}>
<Box textAlign="center">
<Heading size="xl" mb={2}>
新しいアカウントを作成
</Heading>
<Text color="gray.600">
無料でTodoアプリを始めましょう
</Text>
</Box>

<Box as="form" w="full" onSubmit={handleSubmit}>
<VStack spacing={6}>
{error && (
<Alert status="error" borderRadius="md">
<AlertIcon />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}

<VStack spacing={4} w="full">
<FormControl isInvalid={!!validationErrors.name}>
<FormLabel>名前</FormLabel>
<Input
name="name"
type="text"
value={formData.name}
onChange={handleChange}
placeholder="山田太郎"
size="lg"
/>
{validationErrors.name && (
<FormErrorMessage>{validationErrors.name}</FormErrorMessage>
)}
</FormControl>

<FormControl isInvalid={!!validationErrors.email}>
<FormLabel>メールアドレス</FormLabel>
<Input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="email@example.com"
size="lg"
/>
{validationErrors.email && (
<FormErrorMessage>{validationErrors.email}</FormErrorMessage>
)}
</FormControl>

<FormControl isInvalid={!!validationErrors.password}>
<FormLabel>パスワード</FormLabel>
<InputGroup size="lg">
<Input
name="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleChange}
placeholder="••••••••"
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? 'パスワードを隠す' : 'パスワードを表示'}
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPassword(!showPassword)}
variant="ghost"
size="sm"
/>
</InputRightElement>
</InputGroup>
{validationErrors.password && (
<FormErrorMessage>{validationErrors.password}</FormErrorMessage>
)}
</FormControl>

<FormControl isInvalid={!!validationErrors.password_confirmation}>
<FormLabel>パスワード(確認)</FormLabel>
<InputGroup size="lg">
<Input
name="password_confirmation"
type={showPasswordConfirmation ? 'text' : 'password'}
value={formData.password_confirmation}
onChange={handleChange}
placeholder="••••••••"
/>
<InputRightElement>
<IconButton
aria-label={showPasswordConfirmation ? 'パスワードを隠す' : 'パスワードを表示'}
icon={showPasswordConfirmation ? <ViewOffIcon /> : <ViewIcon />}
onClick={() => setShowPasswordConfirmation(!showPasswordConfirmation)}
variant="ghost"
size="sm"
/>
</InputRightElement>
</InputGroup>
{validationErrors.password_confirmation && (
<FormErrorMessage>{validationErrors.password_confirmation}</FormErrorMessage>
)}
</FormControl>
</VStack>

<Button
type="submit"
colorScheme="blue"
size="lg"
w="full"
isLoading={isLoading}
loadingText="作成中..."
>
アカウントを作成
</Button>

<Center>
<Text fontSize="sm" color="gray.600">
すでにアカウントをお持ちの方は{' '}
<Link color="blue.500" onClick={onLoginClick} cursor="pointer">
ログイン
</Link>
</Text>
</Center>
</VStack>
</Box>
</VStack>
</Box>
</Container>
</Box>
);
};

export default SignupForm;

🎯 フォームバリデーションの実装

上記のSignupFormでは、クライアントサイドのバリデーションを実装しています。これにより、サーバーにリクエストを送る前にエラーを検出でき、ユーザー体験が向上します。

📋 バリデーションのポイント

const validate = (): boolean => {
const errors: Partial<SignupFormData> = {};

// 各フィールドのチェック
if (!formData.name) {
errors.name = '名前を入力してください';
}

// メールアドレスの形式チェック
if (!/\S+@\S+\.\S+/.test(formData.email)) {
errors.email = '有効なメールアドレスを入力してください';
}

// パスワードの長さチェック
if (formData.password.length < 6) {
errors.password = 'パスワードは6文字以上で入力してください';
}

// パスワード確認の一致チェック
if (formData.password !== formData.password_confirmation) {
errors.password_confirmation = 'パスワードが一致しません';
}

setValidationErrors(errors);
return Object.keys(errors).length === 0;
};

このようなバリデーションにより:

  • 即座のフィードバック: ユーザーが入力を終えた瞬間にエラーを表示
  • サーバー負荷の軽減: 不正なデータをサーバーに送信しない
  • UXの向上: エラーメッセージが具体的で分かりやすい

7. API通信の改善とTodo機能の認証対応

認証機能が実装できたので、次はTodo機能を認証対応させていきます。第4章で一時的に無効化した認証を再び有効化し、ログインしたユーザーのみが自分のTodoを管理できるようにします。

🔄 型安全なAPI通信の実装

認証が有効になった環境では、すべてのAPIリクエストにCookieを含める必要があります。また、エラーハンドリングも適切に行う必要があります。そこで、API通信を一元管理するユーティリティクラスを作成します。

このクラスには以下の特徴があります:

  • 型安全性: ジェネリクスを使用してレスポンスの型を保証
  • Cookie対応: credentials: 'include'で自動的にCookieを送信
  • エラーハンドリング: 統一的なエラー処理
  • DRY原則: 共通処理をメソッドに集約

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

app/frontend/utils/api.ts
import { ApiError } from '@/types';

class ApiClient {
private baseUrl = '/api/v1';

private async request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include', // Cookieを含める
});

if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.error || 'APIエラーが発生しました');
}

return response.json();
}

// GET リクエスト
async get<T>(path: string): Promise<T> {
return this.request<T>(path);
}

// POST リクエスト
async post<T>(path: string, data: any): Promise<T> {
return this.request<T>(path, {
method: 'POST',
body: JSON.stringify(data),
});
}

// PATCH リクエスト
async patch<T>(path: string, data: any): Promise<T> {
return this.request<T>(path, {
method: 'PATCH',
body: JSON.stringify(data),
});
}

// DELETE リクエスト
async delete(path: string): Promise<void> {
await this.request(path, {
method: 'DELETE',
});
}
}

export const api = new ApiClient();

📝 TodoListの改善

第5章で作ったTodoListを、認証対応とChakra UIでアップグレードします。これにより、以下の改善が実現されます:

  • 認証対応: ログインユーザーのTodoのみを表示・編集
  • エラーハンドリング: ネットワークエラーを適切に処理
  • ローディング状態: データ取得中の表示を改善
  • 型安全性: APIレスポンスの型を保証
  • モダンなUI: Chakra UIによる洗練されたデザイン

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

app/frontend/components/TodoList.tsx
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
Heading,
Text,
Alert,
AlertIcon,
AlertDescription,
Button,
Spinner,
Center,
Badge,
HStack,
Container,
useColorModeValue,
Icon,
Divider,
} from '@chakra-ui/react';
import { RepeatIcon, CheckCircleIcon, ClockIcon } from '@chakra-ui/icons';
import { Todo, CreateTodoInput } from '@/types';
import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/utils/api';
import TodoItem from './TodoItem';
import AddTodoForm from './AddTodoForm';
import TodoFilters from './TodoFilters';

type FilterType = 'all' | 'active' | 'completed';

const TodoList: React.FC = () => {
const { user, isAuthenticated } = useAuth();
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<FilterType>('all');

useEffect(() => {
if (isAuthenticated) {
fetchTodos();
} else {
setTodos([]);
setLoading(false);
}
}, [isAuthenticated]);

const fetchTodos = async () => {
try {
setLoading(true);
const response = await api.get<{ todos: Todo[] }>('/todos');
setTodos(response.todos);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'エラーが発生しました');
} finally {
setLoading(false);
}
};

const handleAddTodo = async (input: CreateTodoInput) => {
try {
const newTodo = await api.post<Todo>('/todos', { todo: input });
setTodos([...todos, newTodo]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Todoの作成に失敗しました');
}
};

const handleToggleTodo = async (id: number) => {
const todo = todos.find(t => t.id === id);
if (!todo) return;

try {
const updatedTodo = await api.patch<Todo>(`/todos/${id}`, {
todo: { completed: !todo.completed }
});
setTodos(todos.map(t => t.id === id ? updatedTodo : t));
} catch (err) {
setError(err instanceof Error ? err.message : 'Todoの更新に失敗しました');
}
};

const handleDeleteTodo = async (id: number) => {
try {
await api.delete(`/todos/${id}`);
setTodos(todos.filter(t => t.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Todoの削除に失敗しました');
}
};

// フィルタリング
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});

// 統計情報
const stats = {
total: todos.length,
active: todos.filter(t => !t.completed).length,
completed: todos.filter(t => t.completed).length,
};

const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');

if (!isAuthenticated) {
return (
<Center minH="100vh" bg={bgColor}>
<VStack spacing={4}>
<Icon as={CheckCircleIcon} boxSize={12} color="gray.400" />
<Heading size="md" color="gray.700">Todoを使用するには</Heading>
<Text color="gray.500">ログインが必要です</Text>
</VStack>
</Center>
);
}

return (
<Container maxW="4xl" py={8}>
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={6}>
<VStack spacing={6} align="stretch">
<Box>
<HStack justify="space-between" flexWrap="wrap" spacing={4}>
<Heading size="lg">
{user?.name}さんのTodo
</Heading>
<HStack spacing={4}>
<Badge variant="subtle" colorScheme="gray" px={3} py={1}>
全て: {stats.total}
</Badge>
<Badge variant="subtle" colorScheme="blue" px={3} py={1}>
<Icon as={ClockIcon} mr={1} />
未完了: {stats.active}
</Badge>
<Badge variant="subtle" colorScheme="green" px={3} py={1}>
<Icon as={CheckCircleIcon} mr={1} />
完了: {stats.completed}
</Badge>
</HStack>
</HStack>
</Box>

{error && (
<Alert status="error" borderRadius="md">
<AlertIcon />
<AlertDescription>
{error}
<Button
size="sm"
variant="link"
colorScheme="red"
ml={2}
onClick={fetchTodos}
leftIcon={<RepeatIcon />}
>
再試行
</Button>
</AlertDescription>
</Alert>
)}

<AddTodoForm onAdd={handleAddTodo} />

<TodoFilters
currentFilter={filter}
onFilterChange={setFilter}
/>

<Box>
{loading ? (
<Center py={12}>
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color="gray.500">読み込み中...</Text>
</VStack>
</Center>
) : filteredTodos.length === 0 ? (
<Center py={12}>
<VStack spacing={2}>
<Icon
boxSize={12}
color="gray.400"
as={() => (
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
)}
/>
<Heading size="sm" color="gray.700">
{filter === 'all'
? 'Todoがありません'
: `${filter === 'active' ? '未完了' : '完了済み'}のTodoがありません`}
</Heading>
{filter === 'all' && (
<Text fontSize="sm" color="gray.500">
新しいTodoを追加してください
</Text>
)}
</VStack>
</Center>
) : (
<VStack divider={<Divider borderColor={borderColor} />} spacing={0} align="stretch">
{filteredTodos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => handleToggleTodo(todo.id)}
onDelete={() => handleDeleteTodo(todo.id)}
/>
))}
</VStack>
)}
</Box>
</VStack>
</Box>
</Container>
);
};

export default TodoList;

📝 TodoItemコンポーネントの改善

TodoアイテムコンポーネントもChakra UIで実装します。Checkboxコンポーネントとアクセシビリティに配慮したUIを提供します。

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

app/frontend/components/TodoItem.tsx
import React from 'react';
import {
Box,
Checkbox,
HStack,
Text,
IconButton,
useColorModeValue,
} from '@chakra-ui/react';
import { DeleteIcon } from '@chakra-ui/icons';
import { Todo } from '@/types/todo';

interface TodoItemProps {
todo: Todo;
onToggle: () => void;
onDelete: () => void;
}

const TodoItem: React.FC<TodoItemProps> = ({ todo, onToggle, onDelete }) => {
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const textColor = useColorModeValue('gray.900', 'gray.100');
const completedTextColor = useColorModeValue('gray.500', 'gray.400');

return (
<Box
py={4}
px={4}
_hover={{ bg: hoverBg }}
transition="background-color 0.15s"
role="group"
>
<HStack spacing={3}>
<Checkbox
isChecked={todo.completed}
onChange={onToggle}
colorScheme="blue"
size="lg"
/>
<Box flex={1}>
<Text
fontSize="md"
fontWeight="medium"
color={todo.completed ? completedTextColor : textColor}
textDecoration={todo.completed ? 'line-through' : 'none'}
>
{todo.title}
</Text>
{todo.description && (
<Text
fontSize="sm"
color={todo.completed ? completedTextColor : 'gray.600'}
mt={1}
>
{todo.description}
</Text>
)}
</Box>
<IconButton
aria-label="削除"
icon={<DeleteIcon />}
size="sm"
colorScheme="red"
variant="ghost"
onClick={onDelete}
opacity={0}
_groupHover={{ opacity: 1 }}
transition="opacity 0.15s"
/>
</HStack>
</Box>
);
};

export default TodoItem;

🎯 TodoFiltersコンポーネント

Todoの表示を「すべて」「未完了」「完了済み」で切り替えるフィルター機能を実装します。Chakra UIのButtonGroupコンポーネントを使用することで、グループ化されたボタンUIを簡単に実現できます。

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

app/frontend/components/TodoFilters.tsx
import React from 'react';
import { Button, ButtonGroup, Icon } from '@chakra-ui/react';
import { CheckCircleIcon, ClockIcon, ViewIcon } from '@chakra-ui/icons';

type FilterType = 'all' | 'active' | 'completed';

interface TodoFiltersProps {
currentFilter: FilterType;
onFilterChange: (filter: FilterType) => void;
}

const TodoFilters: React.FC<TodoFiltersProps> = ({
currentFilter,
onFilterChange,
}) => {
const filters: { value: FilterType; label: string; icon: any }[] = [
{
value: 'all',
label: 'すべて',
icon: ViewIcon
},
{
value: 'active',
label: '未完了',
icon: ClockIcon
},
{
value: 'completed',
label: '完了済み',
icon: CheckCircleIcon
},
];

return (
<ButtonGroup spacing={2} mt={4}>
{filters.map(filter => (
<Button
key={filter.value}
onClick={() => onFilterChange(filter.value)}
leftIcon={<Icon as={filter.icon} />}
colorScheme={currentFilter === filter.value ? 'blue' : 'gray'}
variant={currentFilter === filter.value ? 'solid' : 'outline'}
size="sm"
>
{filter.label}
</Button>
))}
</ButtonGroup>
);
};

export default TodoFilters;

➕ AddTodoFormコンポーネント

新しいTodoを追加するフォームコンポーネントです。Chakra UIのInput、Textarea、Collapseコンポーネントを使用して、使いやすいインターフェースを提供します。

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

app/frontend/components/AddTodoForm.tsx
import React, { useState } from 'react';
import {
Box,
HStack,
Input,
IconButton,
Textarea,
Collapse,
VStack,
useColorModeValue,
} from '@chakra-ui/react';
import { AddIcon } from '@chakra-ui/icons';
import { CreateTodoInput } from '@/types/todo';

interface AddTodoFormProps {
onAdd: (todo: CreateTodoInput) => void;
}

const AddTodoForm: React.FC<AddTodoFormProps> = ({ onAdd }) => {
const [title, setTitle] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [isExpanded, setIsExpanded] = useState<boolean>(false);

const bgColor = useColorModeValue('gray.50', 'gray.700');

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();

if (!title.trim()) return;

onAdd({
title: title.trim(),
description: description.trim() || undefined,
});

setTitle('');
setDescription('');
setIsExpanded(false);
};

return (
<Box as="form" onSubmit={handleSubmit} mb={6}>
<Box bg={bgColor} borderRadius="lg" p={4}>
<VStack spacing={3} align="stretch">
<HStack spacing={2}>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="新しいTodoを追加..."
size="lg"
onFocus={() => setIsExpanded(true)}
/>
<IconButton
type="submit"
aria-label="Todoを追加"
icon={<AddIcon />}
colorScheme="blue"
size="lg"
isDisabled={!title.trim()}
/>
</HStack>

<Collapse in={isExpanded} animateOpacity>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="詳細(任意)"
size="sm"
rows={2}
/>
</Collapse>
</VStack>
</Box>
</Box>
);
};

export default AddTodoForm;

8. アプリケーションの統合

🏠 メインアプリケーションコンポーネント

すべてのコンポーネントを統合して、完全なアプリケーションを構築します。Chakra UIのChakraProviderでアプリケーション全体をラップし、テーマやスタイルを適用します。

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

app/frontend/components/App.tsx
import React, { useState } from 'react';
import { ChakraProvider, Box, Center, VStack, Spinner, Text, useColorModeValue } from '@chakra-ui/react';
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
import LoginForm from './auth/LoginForm';
import SignupForm from './auth/SignupForm';
import TodoList from './TodoList';
import Header from './layout/Header';

const AppContent: React.FC = () => {
const { isAuthenticated, isLoading } = useAuth();
const [showSignup, setShowSignup] = useState(false);
const bgColor = useColorModeValue('gray.50', 'gray.900');

if (isLoading) {
return (
<Center minH="100vh" bg={bgColor}>
<VStack spacing={4}>
<Spinner
thickness="4px"
speed="0.65s"
emptyColor="gray.200"
color="blue.500"
size="xl"
/>
<Text color="gray.600">読み込み中...</Text>
</VStack>
</Center>
);
}

if (!isAuthenticated) {
return showSignup ? (
<SignupForm
onSuccess={() => setShowSignup(false)}
onLoginClick={() => setShowSignup(false)}
/>
) : (
<LoginForm
onSuccess={() => {}}
onSignupClick={() => setShowSignup(true)}
/>
);
}

return (
<Box minH="100vh" bg={bgColor}>
<Header />
<Box as="main" py={10}>
<TodoList />
</Box>
</Box>
);
};

const App: React.FC = () => {
return (
<ChakraProvider>
<AuthProvider>
<AppContent />
</AuthProvider>
</ChakraProvider>
);
};

export default App;

🎯 Headerコンポーネント

アプリケーションのヘッダーをChakra UIで美しくデザインします。レスポンシブ対応とモバイルメニューも実装します。

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

app/frontend/components/layout/Header.tsx
import React from 'react';
import {
Box,
Flex,
HStack,
Text,
Button,
IconButton,
Container,
useDisclosure,
VStack,
useColorModeValue,
Heading,
Drawer,
DrawerBody,
DrawerHeader,
DrawerOverlay,
DrawerContent,
DrawerCloseButton,
} from '@chakra-ui/react';
import { HamburgerIcon } from '@chakra-ui/icons';
import { useAuth } from '@/contexts/AuthContext';

const Header: React.FC = () => {
const { user, logout } = useAuth();
const { isOpen, onOpen, onClose } = useDisclosure();
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');

return (
<Box as="header" bg={bgColor} boxShadow="sm" borderBottom="1px" borderColor={borderColor}>
<Container maxW="7xl" px={{ base: 4, sm: 6, lg: 8 }}>
<Flex h={16} alignItems="center" justifyContent="space-between">
<Heading size="md">Todo App</Heading>

{/* デスクトップメニュー */}
<HStack spacing={4} display={{ base: 'none', md: 'flex' }}>
<Text>こんにちは、{user?.name}さん</Text>
<Button onClick={logout} variant="outline" size="sm">
ログアウト
</Button>
</HStack>

{/* モバイルメニューボタン */}
<IconButton
display={{ base: 'flex', md: 'none' }}
onClick={onOpen}
variant="outline"
aria-label="メニューを開く"
icon={<HamburgerIcon />}
/>
</Flex>
</Container>

{/* モバイルメニュー */}
<Drawer isOpen={isOpen} placement="right" onClose={onClose}>
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader>メニュー</DrawerHeader>
<DrawerBody>
<VStack spacing={4} align="stretch">
<Text>こんにちは、{user?.name}さん</Text>
<Button onClick={() => { logout(); onClose(); }} variant="outline" w="full">
ログアウト
</Button>
</VStack>
</DrawerBody>
</DrawerContent>
</Drawer>
</Box>
);
};

export default Header;

🎨 Chakra UIでのレスポンシブデザイン

今回作成したコンポーネントは、すべてレスポンシブ対応しています。Chakra UIはモバイルファーストの設計で、簡単にレスポンシブデザインを実現できます。

📱 モバイル対応のポイント

// ブレークポイントの使い分け
<Box
px={{ base: 4, sm: 6, lg: 8 }} // レスポンシブなpadding
w={{ base: "100%", md: "50%" }} // レスポンシブな幅
>

// 表示/非表示の切り替え
<Box display={{ base: "none", md: "block" }}> // モバイルで非表示
<Box display={{ base: "block", md: "none" }}> // デスクトップで非表示

🌙 ダークモード対応

// useColorModeValueを使ったダークモード対応
const bgColor = useColorModeValue('white', 'gray.800');
const textColor = useColorModeValue('gray.900', 'white');

<Box bg={bgColor} color={textColor}>
ダークモードに対応したコンテンツ
</Box>

🔄 アニメーションの活用

// Collapseコンポーネントでスライドアニメーション
<Collapse in={isOpen} animateOpacity>
<Box>コンテンツ</Box>
</Collapse>

// トランジションの設定
<Box
transition="all 0.2s"
_hover={{ transform: 'scale(1.05)' }}
>
ホバーで拡大
</Box>

🎯 アクセシビリティへの配慮

作成したコンポーネントはアクセシビリティにも配慮しています:

  • キーボードナビゲーション: focus:クラスでフォーカス状態を明確に
  • スクリーンリーダー対応: sr-onlyクラスで読み上げ専用テキスト
  • ARIA属性: aria-labelでボタンの目的を明示

🔧 エントリーポイントの更新

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

app/frontend/entrypoints/frontend.ts
import React from 'react';
import ReactDOM from 'react-dom';
import rewrap from '@mrhenry/rewrap';

// TailwindCSSをインポート
import '@/styles/application.css';

// メインアプリケーションコンポーネント
import App from '@/components/App';

// Web Componentsとして登録
rewrap('todo-app', App);

console.log('Todo App with TailwindCSS loaded!');

📄 ERBテンプレートの更新

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

app/views/home/index.html.erb
<div id="app">
<!-- Reactアプリケーション全体を表示 -->
<todo-app></todo-app>
</div>

<style>
#app {
min-height: 100vh;
margin: 0;
padding: 0;
}

body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

9. 動作確認とデプロイ準備

🚀 サーバーの起動

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

ターミナルで実行
# 念のためパッケージを再インストール
docker compose exec web npm install

# Viteの開発サーバーを起動
docker compose exec web bin/vite dev

別のターミナルで:

別のターミナルで実行
# Railsサーバーを起動
docker compose exec web rails server -b 0.0.0.0

🧪 動作テスト

  1. http://localhost:3000 にアクセス
  2. ログインフォームが表示されることを確認
  3. 「サインアップ」をクリックして新規登録
  4. 登録後、自動的にログインされることを確認
  5. Todoの追加・完了・削除が動作することを確認
  6. フィルター機能が動作することを確認
  7. ログアウトして再度ログインできることを確認

📏 デプロイ前のチェックリスト

プロダクション環境にデプロイする前に、以下を確認しましょう:

✅ セキュリティ

  • CSRF対策が有効
  • 認証が必須のエンドポイントが保護されている
  • パスワードがbcryptでハッシュ化されている
  • セッションタイムアウトが設定されている

🎯 パフォーマンス

  • TailwindCSSのPurgeが有効(不要なCSSが削除される)
  • JavaScriptのミニファイ
  • 画像の最適化
  • N+1クエリの確認

📦 ビルド

# プロダクションビルド
docker compose exec web rails assets:precompile RAILS_ENV=production

💾 最終コミット

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

ターミナルで実行
# 変更を確認
git status

# すべての変更をステージング
git add .

# コミット
git commit -m "認証統合とTailwindCSSでのリッチUI実装: 完全なTodoアプリケーション完成"

# mainブランチにマージ
git checkout main
git merge feature/chapter-6

📋 まとめ

✅ この章で達成したこと

第6章お疲れさまでした!フルスタックのTodoアプリケーションが完成しました:

  • 🎨 Chakra UIの導入: モダンでレスポンシブなデザイン
  • 📊 UIライブラリの比較: Mantine、MUI、Chakra UI(最終的に採用)、TailwindCSS
  • 📝 実践的なTypeScript: APIレスポンス、フォーム、カスタムフックの型定義
  • 🔐 認証統合: ログイン・サインアップ・ログアウト機能
  • 🌐 Context API: グローバルな認証状態管理
  • ⚠️ エラーハンドリング: ユーザーフレンドリーなエラー表示
  • 🎆 リッチなUI: ローディング、フィルター、統計表示、アニメーション
  • 📱 レスポンシブデザイン: モバイルからPCまで対応
  • 🚀 完成したアプリ: プロダクションレベルの品質

📐 CSS Modules vs Chakra UI

第5章と第6章で異なるスタイリング方法を体験しました:

項目CSS Modules(第5章)Chakra UI(第6章)
開発速度普通非常に速い
カスタマイズ完全に自由テーマシステム内
バンドルサイズ小さいtree shakingで最適化
学習コスト低い中程度
チーム開発独立性高い統一性高い
アクセシビリティ自分で実装標準でサポート

🎯 学習成果

このカリキュラムを通じて、以下のスキルが身につきました:

バックエンド(Rails)

  • Docker環境構築
  • Rails APIモード
  • CRUD実装
  • Cookie認証
  • CSRF対策

フロントエンド(React + TypeScript)

  • TypeScriptの基礎と実践
  • Reactコンポーネント設計
  • カスタムフック
  • Context API
  • エラーハンドリング

フルスタック開発

  • API設計
  • 認証フロー
  • 状態管理
  • UI/UX設計

🚀 次のステップ

さらにスキルアップしたい方は:

  1. テストの追加

    • RSpec(Rails)
    • Jest + React Testing Library(React)
  2. 機能拡張

    • タグ機能
    • 検索機能
    • ソート機能
    • 期限設定
  3. パフォーマンス改善

    • ページネーション
    • 無限スクロール
    • メモ化
  4. デプロイ

    • Heroku
    • Render
    • AWS

🏃 演習問題

📝 演習1: リアルタイム検索

Todo一覧にリアルタイム検索機能を追加してください。

💡 ヒント(クリックで展開)
const [searchTerm, setSearchTerm] = useState('');

const filteredTodos = todos.filter(todo => {
const matchesFilter = /* 既存のフィルター */;
const matchesSearch = todo.title.toLowerCase()
.includes(searchTerm.toLowerCase());
return matchesFilter && matchesSearch;
});

📝 演習2: ダークモード

アプリケーション全体にダークモードを実装してください。

💡 ヒント(クリックで展開)
  • Context APIでテーマ状態を管理
  • CSS変数を使って色を定義
  • localStorage に設定を保存

📚 参考資料


お疲れさまでした!これでフルスタックのTodoアプリケーションが完成しました🎉