はじめに
こんにちは!この記事では、React HooksとTypeScriptを使ってTODOアプリを作成する方法を解説します。
近年、React開発ではClass ComponentからFunction Componentへのシフトが進み、Hooksを使った状態管理が主流となっています。本記事では、実際に動作するTODOアプリを通じて、React Hooksの基本的な使い方を学んでいきます。
記事の目的・対象読者
目的
- React Hooksの基本的な使い方を理解する
- useState、useEffectの実践的な活用方法を学ぶ
- カスタムフックを使った状態管理パターンを習得する
- Atomic Designの基本概念を理解する
対象読者
- React/TypeScriptの基本文法を理解している方
- Hooksの概念は知っているが実践経験が少ない方
- コンポーネント設計のベストプラクティスを学びたい方
ソースコード
プロジェクト構成
ディレクトリ構造と概要
src/
├── features/ # 機能別モジュール
│ └── todos/ # TODOリスト機能
│ ├── components/ # 機能専用コンポーネント
│ │ ├── TaskForm/ # タスク入力フォーム
│ │ ├── TaskItem/ # 個別タスク項目
│ │ ├── FilterButtons/ # フィルタボタン群
│ │ └── TaskCounter/ # タスクカウンター
│ ├── TodoApp/ # TODO機能のルートコンポーネント
│ ├── useTasks.ts # タスク管理カスタムフック
│ └── types.ts # TODO機能の型定義
├── components/ # 共通UIコンポーネント
│ ├── Button/ # ボタン
│ ├── Input/ # テキスト入力
│ └── Checkbox/ # チェックボックス
├── types/ # 汎用的な型定義
├── stories/ # Storybook用ストーリー
├── App.tsx # メインアプリ
└── main.tsx # エントリーポイント
Feature-Sliced Design + Atomic Design
本プロジェクトはFeature-Sliced Designを採用し、Atomic Designの考え方を部分的に取り入れています。
| 階層 | 役割 | 例 |
|---|---|---|
|
Feature固有コンポーネント (features/todos/components/) |
Todo機能専用のコンポーネント | TaskForm, TaskItem, FilterButtons |
|
Featureコンテナ (features/todos/) |
Feature全体を統合 | TodoApp, useTasks |
|
共有コンポーネント (components/) |
複数のFeatureで再利用される基本UI要素 | Button, Input, Checkbox |
なぜFeature-Sliced Designなのか?
機能(Feature)ごとにコードをまとめることで、関連するコンポーネント・ロジック・型定義が同じディレクトリに配置され、保守性が向上します。新機能追加時も影響範囲が明確で、スケーラビリティに優れています。一方、複数Featureで共有される基本UI要素はcomponents/に配置し、再利用性を確保しています。
コンポーネント構成図
Button、Input、Checkboxといった 共有UI要素が、複数のFeature固有コンポーネント から利用されていることがわかります。
アプリの基本フロー
TODO追加から表示までの流れ
ユーザーがタスクを追加してから画面に表示されるまでの一連の流れを見ていきます。
主要な処理の流れ
-
入力受付:
Inputコンポーネントでユーザー入力を受け取る - バリデーション: 空文字列でないことを確認
- タスク生成: 一意のID(タイムスタンプ)を持つTaskオブジェクトを作成
-
状態更新:
useStateで管理されているtasks配列に追加 - 永続化: localStorageに保存
- フィルタリング: 現在のフィルタ条件(すべて/未完了/完了済み)に応じて表示
- レンダリング: Reactが自動的に画面を更新
useStateが再レンダリングを引き起こす理由
ReactのuseStateで状態を更新すると、該当するコンポーネントが再レンダリングされます。これにより、最新のタスク一覧が自動的に画面に反映されます。手動でDOMを操作する必要はありません。
実装
1. 型定義
アプリケーションで使用する型は、グローバル型とFeature固有型に分けて定義します。
グローバル型定義(共有UI要素用)
// 汎用UIコンポーネントの型定義
export interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
variant?: 'primary' | 'secondary' | 'danger';
className?: string;
disabled?: boolean;
}
export interface InputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
onKeyPress?: (key: string) => void;
className?: string;
}
export interface CheckboxProps {
checked: boolean;
onChange: (checked: boolean) => void;
className?: string;
}
Feature固有型定義(Todo機能用)
// TODO機能固有の型定義
// タスクの型定義
export interface Task {
id: number; // 一意のID(タイムスタンプ)
text: string; // タスクの内容
completed: boolean; // 完了状態
createdAt: Date; // 作成日時
}
// フィルタタイプ(すべて/未完了/完了済み)
export type FilterType = 'all' | 'active' | 'completed';
// 各コンポーネントのProps型定義
export interface TaskFormProps {
onAddTask: (text: string) => void;
}
export interface TaskItemProps {
task: Task;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
export interface FilterButtonsProps {
currentFilter: FilterType;
onFilterChange: (filter: FilterType) => void;
}
export interface TaskCounterProps {
activeCount: number;
onClearCompleted: () => void;
}
2. カスタムフック:useTasks
タスクの状態管理を行うカスタムフックです。このフックがアプリケーションの中核となります。
カスタムフックとは?
React Hooks(useStateやuseEffectなど)を組み合わせて、共通のロジックを再利用できる仕組みです。useTasksはタスクの追加・削除・保存・フィルタリングなどのロジックをまとめており、UIコンポーネントから分離することで可読性と再利用性を高めています。
import { useState, useEffect } from 'react';
import { Task, FilterType } from './types';
export const useTasks = () => {
// タスクリストの状態管理
const [tasks, setTasks] = useState<Task[]>([]);
// 現在のフィルタ状態管理
const [currentFilter, setCurrentFilter] = useState<FilterType>('all');
// 初回マウント時:localStorageからタスクを読み込む
useEffect(() => {
const savedTasks = localStorage.getItem('tasks');
if (savedTasks) {
const parsedTasks = JSON.parse(savedTasks).map((task: any) => ({
...task,
createdAt: new Date(task.createdAt), // Date型に変換
}));
setTasks(parsedTasks);
}
}, []); // 空配列 = 初回のみ実行
// タスクを保存する共通関数
const saveTasks = (updatedTasks: Task[]) => {
localStorage.setItem('tasks', JSON.stringify(updatedTasks));
setTasks(updatedTasks);
};
// タスク追加
const addTask = (text: string) => {
const newTask: Task = {
id: Date.now(), // 現在のタイムスタンプをIDとして使用
text,
completed: false,
createdAt: new Date(),
};
const updatedTasks = [...tasks, newTask];
saveTasks(updatedTasks);
};
// タスクの完了/未完了を切り替え
const toggleTask = (id: number) => {
const updatedTasks = tasks.map(task =>
task.id === id ? { ...task, completed: !task.completed } : task
);
saveTasks(updatedTasks);
};
// タスク削除
const deleteTask = (id: number) => {
const updatedTasks = tasks.filter(task => task.id !== id);
saveTasks(updatedTasks);
};
// 完了済みタスクを一括削除
const clearCompleted = () => {
const updatedTasks = tasks.filter(task => !task.completed);
saveTasks(updatedTasks);
};
// フィルタリングとソート
const filteredTasks = tasks
.filter(task => {
if (currentFilter === 'active') return !task.completed;
if (currentFilter === 'completed') return task.completed;
return true; // 'all'の場合
})
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); // 作成日時の昇順
// 未完了タスクの数をカウント
const activeTaskCount = tasks.filter(task => !task.completed).length;
// フックから返す値
return {
tasks: filteredTasks,
currentFilter,
activeTaskCount,
addTask,
toggleTask,
deleteTask,
clearCompleted,
setCurrentFilter,
};
};
3. 共有UIコンポーネント
Button コンポーネント
import { ButtonProps } from '../../types';
import styles from './Button.module.css';
export const Button: React.FC<ButtonProps> = ({
children,
onClick,
variant = 'primary',
className = '',
disabled = false,
}) => {
// 複数のクラス名を結合
const buttonClass = [
styles.button,
styles[variant],
className,
].filter(Boolean).join(' ');
return (
<button
className={buttonClass}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
};
Input コンポーネント
import { InputProps } from '../../types';
import styles from './Input.module.css';
export const Input: React.FC<InputProps> = ({
value,
onChange,
placeholder = '',
onKeyPress,
className = '',
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (onKeyPress) {
onKeyPress(e.key);
}
};
return (
<input
type="text"
value={value}
onChange={handleChange}
onKeyDown={handleKeyPress}
placeholder={placeholder}
className={[styles.input, className].filter(Boolean).join(' ')}
/>
);
};
Checkbox コンポーネント
import { CheckboxProps } from '../../types';
import styles from './Checkbox.module.css';
export const Checkbox: React.FC<CheckboxProps> = ({
checked,
onChange,
className = '',
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.checked);
};
return (
<input
type="checkbox"
checked={checked}
onChange={handleChange}
className={[styles.checkbox, className].filter(Boolean).join(' ')}
/>
);
};
4. Feature固有コンポーネント
TaskForm コンポーネント
import { useState } from 'react';
import { Button } from '../../../components/Button';
import { Input } from '../../../components/Input';
import { TaskFormProps } from '../types';
import styles from './TaskForm.module.css';
export const TaskForm: React.FC<TaskFormProps> = ({ onAddTask }) => {
const [taskText, setTaskText] = useState('');
const handleSubmit = () => {
const trimmedText = taskText.trim();
if (trimmedText !== '') {
onAddTask(trimmedText);
setTaskText(''); // 入力フィールドをクリア
}
};
const handleKeyPress = (key: string) => {
if (key === 'Enter') {
handleSubmit();
}
};
return (
<div className={styles.taskForm}>
<Input
value={taskText}
onChange={setTaskText}
placeholder="新しいタスクを入力..."
onKeyPress={handleKeyPress}
className={styles.input}
/>
<Button onClick={handleSubmit} className={styles.button}>
追加
</Button>
</div>
);
};
TaskItem コンポーネント
import { Button } from '../../../components/Button';
import { Checkbox } from '../../../components/Checkbox';
import { TaskItemProps } from '../types';
import styles from './TaskItem.module.css';
export const TaskItem: React.FC<TaskItemProps> = ({
task,
onToggle,
onDelete,
}) => {
const handleToggle = () => {
onToggle(task.id);
};
const handleDelete = () => {
onDelete(task.id);
};
return (
<li className={`${styles.taskItem} ${task.completed ? styles.completed : ''}`}>
<Checkbox
checked={task.completed}
onChange={handleToggle}
/>
<span className={styles.taskText}>{task.text}</span>
<Button
onClick={handleDelete}
variant="danger"
className={styles.deleteButton}
>
削除
</Button>
</li>
);
};
FilterButtons コンポーネント
import { Button } from '../../../components/Button';
import { FilterButtonsProps, FilterType } from '../types';
import styles from './FilterButtons.module.css';
export const FilterButtons: React.FC<FilterButtonsProps> = ({
currentFilter,
onFilterChange,
}) => {
const filters: { key: FilterType; label: string }[] = [
{ key: 'all', label: 'すべて' },
{ key: 'active', label: '未完了' },
{ key: 'completed', label: '完了済み' },
];
return (
<div className={styles.filterButtons}>
{filters.map(({ key, label }) => (
<Button
key={key}
onClick={() => onFilterChange(key)}
variant={currentFilter === key ? 'primary' : 'secondary'}
className={styles.filterButton}
>
{label}
</Button>
))}
</div>
);
};
TaskCounter コンポーネント
import { Button } from '../../../components/Button';
import { TaskCounterProps } from '../types';
import styles from './TaskCounter.module.css';
export const TaskCounter: React.FC<TaskCounterProps> = ({
activeCount,
onClearCompleted,
}) => {
return (
<div className={styles.taskCounter}>
<span className={styles.count}>{activeCount} 個のタスク</span>
<Button
onClick={onClearCompleted}
variant="secondary"
className={styles.clearButton}
>
完了したタスクを削除
</Button>
</div>
);
};
5. Featureコンテナ
TodoApp コンポーネント
アプリケーション全体を統合する最上位コンポーネントです。
import { TaskForm } from './components/TaskForm';
import { FilterButtons } from './components/FilterButtons';
import { TaskItem } from './components/TaskItem';
import { TaskCounter } from './components/TaskCounter';
import { useTasks } from './useTasks';
import styles from './TodoApp.module.css';
export const TodoApp: React.FC = () => {
const {
tasks,
currentFilter,
activeTaskCount,
addTask,
toggleTask,
deleteTask,
clearCompleted,
setCurrentFilter,
} = useTasks();
return (
<div className={styles.container}>
<h1 className={styles.title}>To-Doリスト</h1>
{/* タスク追加フォーム */}
<TaskForm onAddTask={addTask} />
{/* フィルタボタン */}
<FilterButtons
currentFilter={currentFilter}
onFilterChange={setCurrentFilter}
/>
{/* タスクリスト */}
<ul className={styles.taskList}>
{tasks.length === 0 ? (
<li className={styles.emptyMessage}>タスクがありません</li>
) : (
tasks.map(task => (
<TaskItem
key={task.id}
task={task}
onToggle={toggleTask}
onDelete={deleteTask}
/>
))
)}
</ul>
{/* タスクカウンターと完了済み削除ボタン */}
<TaskCounter
activeCount={activeTaskCount}
onClearCompleted={clearCompleted}
/>
</div>
);
};
6. App.tsx
import { TodoApp } from './features/todos/TodoApp/TodoApp';
import './App.css';
function App() {
return <TodoApp />;
}
export default App;
状態管理
状態管理とは?
「状態(state)」とは、アプリの中で変化するデータのことを指します。例えば、タスクの一覧やフィルタ状態などがそれにあたります。ReactではuseStateやカスタムフックを使ってこの状態を安全に管理します。
シーケンス図:タスク追加
ユーザーがタスクを追加する際のコンポーネント間のやり取りを示します。
シーケンス図:タスク完了切り替え
タスクの完了/未完了を切り替える操作の流れです。
シーケンス図:フィルタリング
フィルタボタンをクリックした際の動作です。
重要なポイント
1. 単方向データフロー
Reactでは、データは常に親から子へpropsとして流れます。
TodoApp (状態を保持)
↓ props
↓ tasks, onToggle, onDelete
↓
TaskItem (表示のみ)
子コンポーネントから親の状態を変更する場合は、親から渡されたコールバック関数を使用します。
2. 状態のリフトアップ
複数のコンポーネントで共有する状態は、最も近い共通の親コンポーネントで管理します。本アプリでは、すべての状態をuseTasksフックで一元管理し、TodoAppから各子コンポーネントに配布しています。
3. 不変性(Immutability)
Reactの状態を更新する際は、元のオブジェクトを直接変更せず、新しいオブジェクトを作成します。
// ❌ 悪い例:元の配列を直接変更
tasks.push(newTask);
setTasks(tasks);
// ✅ 良い例:新しい配列を作成
const updatedTasks = [...tasks, newTask];
setTasks(updatedTasks);
4. useEffectによる副作用管理
useEffectは、コンポーネントの外部との同期(副作用)を管理します。
useEffect(() => {
// 副作用:localStorageからの読み込み
const savedTasks = localStorage.getItem('tasks');
if (savedTasks) {
setTasks(JSON.parse(savedTasks));
}
}, []); // 依存配列が空 = マウント時のみ実行
まとめ
これまでに、React Hooks、カスタムフック、Feature-Sliced Designを使って、シンプルながら拡張性の高いTODOアプリを構築しました。本章では、学んだ内容を振り返り、どのようにプロジェクト設計や状態管理に応用できるかを整理します。
学んだこと
- useState: コンポーネント内の状態管理
- useEffect: 副作用(localStorage連携)の処理
- カスタムフック: ロジックの再利用可能な形での抽出
- Feature-Sliced Design: 機能単位でのコード整理
- コンポーネント設計: 共有UI要素とFeature固有コンポーネントの分離
- Props Drilling: 親から子への状態とコールバックの受け渡し
- Immutability: 状態更新時の不変性の維持