ReactでTODOアプリを作るハンズオン ~追加・完了・削除機能を学ぼう~
読者対象
この記事は、React初心者を対象としています。以下の知識があることを前提としています:
- Reactの基本的な知識(コンポーネント、JSX)
- TypeScriptの基礎
- Hooks(
useState)の基本的な使い方 - 配列の
mapメソッドの基本的な理解
前提条件・環境構築
必要な知識
- Reactの基本的な知識(コンポーネント、JSX)
- TypeScriptの基礎
- Hooks(
useState)の基本的な使い方 - 配列の
mapメソッドの基本的な理解
環境構築
この記事では、Next.js + TypeScript + Tailwind CSSの環境を使用します。
環境構築の詳細は以下のコマンドでセットアップできます:
npx create-next-app@latest todo-app --typescript --tailwind --app
または、既存のプロジェクトをお持ちの場合は、以下の依存関係がインストールされていることを確認してください:
- React 18.1.0以上
- TypeScript 5.3.3以上
- Next.js 14.1.0以上
TODOアプリの完成形
今回作成するTODOアプリは以下の機能を持ちます:
- 追加機能: テキスト入力欄に入力したタスクを追加できる
- 完了機能: 追加したタスクを完了状態にできる(完了済みのタスクは未完了に戻せる)
- 削除機能: 追加したタスクを個別に削除できる
全体像の把握:ベタ書きコードと実際の挙動
学習を始める前に、まず完成形のコード全体を見て、どのような挙動をするのかを確認しましょう。
ベタ書きコード(全体像)
以下は、コンポーネント分割前のベタ書きコードです。全てのロジックが1つのファイルにまとまっています:
import { NextPage } from 'next';
import { useState } from 'react';
import Button from '@/components/common/parts/Button';
type Task = {
label: string;
completed: boolean;
};
const Page: NextPage = () => {
const [inputValue, setInputValue] = useState<string>('');
const [tasks, setTasks] = useState<Task[]>([]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.currentTarget.value);
};
const handleTaskAdd = () => {
if (!inputValue) {
return;
}
setTasks((prev) => [...prev, { label: inputValue, completed: false }]);
setInputValue('');
};
const handleTaskComplete = (index: number) => {
setTasks((prevTasks) =>
prevTasks.map((prevTask, i) =>
i === index ? { ...prevTask, completed: !prevTask.completed } : prevTask,
),
);
};
const handleTaskDelete = (index: number) => {
setTasks((prevTasks) => prevTasks.filter((_, i) => i !== index));
};
return (
<div className="mx-auto mt-10 max-w-4xl">
<div className="flex justify-center">
<div>
{/* 入力フォーム */}
<div>
<input
type="text"
placeholder="タスクを入力"
className="mb-5 rounded-md border-solid text-center"
value={inputValue}
onChange={handleInputChange}
/>
<Button variant="primary" label="追加" onClick={handleTaskAdd} />
</div>
{/* タスク一覧 */}
<div>
<ul>
{tasks.map((task, index) => {
return (
<li key={index} className="mb-3 flex items-center justify-between">
<span className={task.completed ? 'text-gray-500 line-through' : ''}>
{task.label}
</span>
<div className="flex gap-2">
<Button
variant="secondary"
label={task.completed ? '未完了' : '完了'}
onClick={() => handleTaskComplete(index)}
/>
<Button
variant="primary"
label="削除"
onClick={() => handleTaskDelete(index)}
/>
</div>
</li>
);
})}
</ul>
</div>
</div>
</div>
</div>
);
};
export default Page;
実際の挙動
1 追加機能: 入力欄にテキストを入力し、「追加」ボタンをクリックすると、タスクが一覧に追加されます
2 完了機能: 各タスクの横にある「完了」ボタンをクリックすると、タスクが完了状態になり、打ち消し線とグレー表示が適用されます。完了済みのタスクでは「未完了」ボタンが表示され、クリックすると未完了状態に戻ります
3 削除機能: 各タスクの横にある「削除」ボタンをクリックすると、該当するタスクが一覧から削除されます
このコードの各部分がどのように動作するのか、以下で詳しく解説していきます。
1. ステート設計:何をステートとして管理すべきか?
Reactでアプリケーションを開発する際、最も重要なのは「何をステートとして管理すべきか」を判断することです。TODOアプリの場合、以下の2つのステートが必要です:
const [inputValue, setInputValue] = useState<string>('');
const [tasks, setTasks] = useState<Task[]>([]);
1. inputValue: 現在入力中のテキスト
const [inputValue, setInputValue] = useState<string>('');
このステートは、入力フォームの現在の値を保持します。ユーザーが入力欄に文字を入力すると、このステートが更新され、入力欄と同期します。
2. tasks: 追加されたタスクの一覧(オブジェクト配列)
const [tasks, setTasks] = useState<Task[]>([]);
このステートは、これまでに追加された全てのタスクを配列として保持します。空配列で初期化し、タスクが追加されるたびに配列に要素が追加されていきます。
重要なポイント: メモアプリと異なり、TODOアプリではオブジェクトの配列を管理します。各タスクは以下の情報を持ちます:
type Task = {
label: string; // タスクのテキスト
completed: boolean; // 完了状態(true: 完了済み、false: 未完了)
};
なぜオブジェクト配列なのか?
TODOアプリでは、単純な文字列の配列ではなく、オブジェクトの配列が必要です。理由は以下の通りです:
- 完了状態の管理: 各タスクが完了しているかどうかを追跡する必要がある(それぞれのタスクがcompletedという状態をタスク名とは別にセットで持つ必要があるため)
- 機能の拡張性: 将来的に優先度や期限などの情報を追加する際に、オブジェクト構造の方が拡張しやすい
2. 入力値の管理とタスクの追加
入力値をステートと同期させる(制御コンポーネント)
Reactで入力フォームを扱う場合、**制御コンポーネント(Controlled Component)**のパターンを使用します。
<input
type="text"
placeholder="タスクを入力"
value={inputValue} // ステートの値と同期
onChange={handleInputChange} // 入力時にステートを更新
/>
value={inputValue} により、入力欄の値は常に inputValue ステートと同期します。
入力変更時のハンドラー関数
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.currentTarget.value);
};
この関数により、ユーザーが入力欄に文字を入力するたびに、inputValue ステートが更新されます。
タスク追加機能の実装
タスクを追加するには、入力された値を tasks 配列に追加する必要があります。
const handleTaskAdd = () => {
if (!inputValue) {
return; // 空のタスクは追加しない
}
setTasks((prev) => [...prev, { label: inputValue, completed: false }]);
setInputValue('');
};
コードの詳細解説
1. 空文字チェック
if (!inputValue) {
return;
}
空のタスクが追加されないように、事前にチェックします。
2. オブジェクト配列への追加
setTasks((prev) => [...prev, { label: inputValue, completed: false }]);
ここで重要なのは、オブジェクトを配列に追加していることです。
-
prev: 現在のtasks配列 -
[...prev, { label: inputValue, completed: false }]: スプレッド構文で既存の配列を展開し、新しいタスクオブジェクトを追加 -
{ label: inputValue, completed: false }: 新しいタスクオブジェクト。completedはfalseで初期化(タスクが未完了状態)
3. 入力欄のクリア
setInputValue('');
タスクを追加した後、入力欄を空に戻します。
3. 完了機能の実装:mapメソッドでオブジェクトを更新
完了機能のロジック
タスクを完了状態にするには、配列内の特定のオブジェクトの completed プロパティを更新する必要があります。Reactでは、map メソッドを使用します。
const handleTaskComplete = (index: number) => {
setTasks((prevTasks) =>
prevTasks.map((prevTask, i) =>
i === index ? { ...prevTask, completed: !prevTask.completed } : prevTask,
),
);
};
mapメソッドによるオブジェクト更新の詳細解説
1. map の基本構文(オブジェクト配列の場合)
array.map((要素, インデックス) => {
return 新しい要素; // オブジェクトを返す
});
-
prevTask: 配列の各要素(この場合はTaskオブジェクト) -
i: 要素のインデックス番号(0, 1, 2...)
2. 条件分岐による更新
i === index ? { ...prevTask, completed: !prevTask.completed } : prevTask;
この部分は三項演算子を使用して、以下のロジックを実現しています:
-
i === index: 更新対象のタスクかどうかを判定 -
{ ...prevTask, completed: !prevTask.completed }: 更新対象の場合、スプレッド構文で既存のオブジェクトを展開し、completedプロパティだけを反転(!prevTask.completed) -
prevTask: 更新対象でない場合、元のオブジェクトをそのまま返す
3. スプレッド構文によるオブジェクトの部分更新
{ ...prevTask, completed: !prevTask.completed }
スプレッド構文 ...prevTask により、既存のオブジェクトの全プロパティを展開し、その後で completed プロパティを上書きします。これにより、イミュータブル(不変)な更新が実現されます。
具体例:
更新前の tasks:
[
{ label: 'タスク1', completed: false },
{ label: 'タスク2', completed: false },
{ label: 'タスク3', completed: false },
];
index = 1("タスク2"を完了)の場合:
prevTasks.map((prevTask, i) =>
i === 1 ? { ...prevTask, completed: !prevTask.completed } : prevTask,
);
評価:
-
i = 0:0 === 1→false→{ label: 'タスク1', completed: false }を返す -
i = 1:1 === 1→true→{ ...{ label: 'タスク2', completed: false }, completed: true }→{ label: 'タスク2', completed: true }を返す -
i = 2:2 === 1→false→{ label: 'タスク3', completed: false }を返す
結果:
[
{ label: 'タスク1', completed: false },
{ label: 'タスク2', completed: true }, // completedがtrueに変更
{ label: 'タスク3', completed: false },
];
4. トグル機能(完了⇔未完了)
!prevTask.completed により、completed の値を反転(トグル)しています:
-
completed: false→!false→true(完了状態にする) -
completed: true→!true→false(未完了状態に戻す)
これにより、完了ボタンを押すたびに完了状態と未完了状態が切り替わります。
4. 削除機能の実装:filterメソッドで要素を除外
削除機能のロジック
タスクを削除するには、配列から特定の要素を取り除く必要があります。Reactでは、filter メソッドを使用します。
const handleTaskDelete = (index: number) => {
setTasks((prevTasks) => prevTasks.filter((_, i) => i !== index));
};
filterメソッドの詳細解説
1. filter の基本構文
array.filter((要素, インデックス) => {
return 条件式; // trueを返した要素だけが残る
});
filter は、条件に一致する要素だけを残した新しい配列を返します。
2. 削除ロジックの動作
prevTasks.filter((_, i) => i !== index);
-
prevTasks: 現在のtasks配列 -
_: 配列の要素(今回は使用しないため_で無視) -
i: 要素のインデックス -
i !== index: 削除対象のインデックス以外の要素だけを残す
具体例:
削除前の tasks:
[
{ label: 'タスク1', completed: false },
{ label: 'タスク2', completed: true },
{ label: 'タスク3', completed: false },
];
index = 1("タスク2"を削除)の場合:
prevTasks.filter((_, i) => i !== 1);
評価:
-
i = 0:0 !== 1→true→ "タスク1" を残す -
i = 1:1 !== 1→false→ "タスク2" を削除 -
i = 2:2 !== 1→true→ "タスク3" を残す
結果:
[
{ label: 'タスク1', completed: false },
{ label: 'タスク3', completed: false },
];
5. ステートとスタイリングの組み合わせ:三項演算子の活用
よく使うテクニックなので重要です!!ここでしっかりと考え方をマスターしましょう
TODOアプリの特徴的な部分は、ステートに基づいてUIの見た目を動的に変更することです。
これは、三項演算子を使って実現します。
タスクラベルのスタイリング
<span className={task.completed ? 'text-gray-500 line-through' : ''}>{task.label}</span>
三項演算子による条件付きスタイリング
task.completed ? 'text-gray-500 line-through' : '';
-
task.completed: タスクの完了状態をチェック -
trueの場合:'text-gray-500 line-through'を適用(グレー表示 + 打ち消し線) -
falseの場合: 空文字列(スタイルなし)
これにより、完了済みのタスクは視覚的に区別されます。
ボタンラベルの動的変更
<Button
variant="secondary"
label={task.completed ? '未完了' : '完了'}
onClick={() => handleTaskComplete(index)}
/>
三項演算子によるラベルの切り替え
task.completed ? '未完了' : '完了';
-
task.completedがtrueの場合: 「未完了」ボタンを表示 -
task.completedがfalseの場合: 「完了」ボタンを表示
これにより、タスクの状態に応じてボタンのラベルが自動的に切り替わります。
ステートとスタイリングの組み合わせ方の考え方
このパターンは、Reactでよく使用される重要な考え方です:
- ステートに基づく条件分岐: ステートの値に応じて、UIの見た目や動作を変更する
- 三項演算子の活用: シンプルな条件分岐には三項演算子が適している
- 一貫性の維持: ステートが変更されると、自動的にUIも更新される
メリット:
- 宣言的: 「どのように」ではなく「何を」表示するかを記述
- 自動同期: ステートが更新されると、自動的にUIも更新される
- 保守性: ステートとUIの関係が明確で、変更が容易
JSの文法的には参考演算子とテンプレートリテラルを利用しているので
自信がない方はJSの文法を復習してみるのもアリです。
6. 配列のレンダリング:mapメソッドでオブジェクト配列を表示
タスク一覧の表示
追加されたタスクを画面上に表示するには、配列の各要素(オブジェクト)をJSX要素に変換する必要があります。
<ul>
{tasks.map((task, index) => {
return (
<li key={index} className="mb-3 flex items-center justify-between">
<span className={task.completed ? 'text-gray-500 line-through' : ''}>{task.label}</span>
<div className="flex gap-2">
<Button
variant="secondary"
label={task.completed ? '未完了' : '完了'}
onClick={() => handleTaskComplete(index)}
/>
<Button variant="primary" label="削除" onClick={() => handleTaskDelete(index)} />
</div>
</li>
);
})}
</ul>
mapメソッドとオブジェクト配列
1. オブジェクト配列のmap
tasks.map((task, index) => {
return JSX要素;
});
-
task: 配列の各要素(この場合はTaskオブジェクト) -
index: 要素のインデックス番号
2. オブジェクトのプロパティへのアクセス
{
task.label;
} // オブジェクトのlabelプロパティを表示
オブジェクト配列の場合、各要素はオブジェクトなので、task.label のようにプロパティにアクセスします。
3. ボタンへのインデックス渡し
onClick={() => handleTaskComplete(index)}
onClick={() => handleTaskDelete(index)}
各ボタンに、map で取得した index を渡すことで、どのタスクに対する操作かを識別します。
重要: index を key として使用することについて
この記事のコード例では、簡易的な実装として key={index} を使用していますが、実際のプロダクションでは推奨されません。
なぜ index を key として使うべきでないのか?
要素の順序が変わると、残りの要素のインデックスも全て変わるため、Reactが正しく要素を識別できず、予期しない挙動やパフォーマンスの問題が発生する可能性があります。また、順序が変わることで、意図しないコンポーネントに状態が引き継がれる可能性もあります。
今回は簡潔に説明するため文字列の配列を使用しているため、簡易的に index を使用していますが、実際のアプリケーションでは各要素を一意に識別できる値(ID)を key として使用することを推奨します。
詳細な解説については、以下の公式ドキュメントを参照してください:
最後にコンポーネント分割して役割ごとにファイルを分けましょう!!
これまで、全てのロジックを1つのファイルにまとめたベタ書きコードで実装してきました。しかし、実際のアプリケーション開発では、コードを役割ごとに分割して管理することが重要です。
コンポーネント分割により、以下のメリットが得られます:
- 保守性の向上: 各ファイルの役割が明確になり、変更が容易になる
- 再利用性の向上: ロジックやUIコンポーネントを他の場所でも使いやすくなる
- テストの容易さ: 各機能を個別にテストできる
- 可読性の向上: コードが整理され、理解しやすくなる
それでは、TODOアプリを以下の4つのファイルに分割していきましょう:
- 型定義ファイル: データの型を定義
- ロジックファイル: ステート管理とビジネスロジック
- UIコンポーネントファイル: 画面表示のロジック
- ページコンポーネントファイル: ページ全体の構造
型定義(src/types/UseTodo.ts)
type Task = {
label: string;
completed: boolean;
};
type UseTodo = {
inputValue: string;
tasks: Task[];
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleTaskAdd: () => void;
handleTaskComplete: (index: number) => void;
handleTaskDelete: (index: number) => void;
};
export type { Task, UseTodo };
型定義ファイルでは、アプリケーションで使用するデータの型を定義します。
-
Task: 個々のタスクを表すオブジェクトの型 -
UseTodo: カスタムフックが返す値の型定義
ロジック(src/lib/useTodo.ts)
import { ChangeEvent, useState } from 'react';
import type { Task, UseTodo } from '@/types/UseTodo';
const useTodo = (): UseTodo => {
const [inputValue, setInputValue] = useState<string>('');
const [tasks, setTasks] = useState<Task[]>([]);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.currentTarget.value);
};
const handleTaskAdd = () => {
if (!inputValue) {
return;
}
setTasks((prev) => [...prev, { label: inputValue, completed: false }]);
setInputValue('');
};
const handleTaskComplete = (index: number) => {
setTasks((prevTasks) =>
prevTasks.map((prevTask, i) =>
i === index ? { ...prevTask, completed: !prevTask.completed } : prevTask,
),
);
};
const handleTaskDelete = (index: number) => {
setTasks((prevTasks) => prevTasks.filter((_, i) => i !== index));
};
return {
inputValue,
tasks,
handleInputChange,
handleTaskAdd,
handleTaskComplete,
handleTaskDelete,
};
};
export default useTodo;
ロジックファイルでは、ステート管理とビジネスロジックを実装します。
-
useStateによるステート管理 - 各イベントハンドラー関数の実装
- カスタムフックとして、必要な値と関数を返す
このファイルは、UIとは独立したロジックを担当します。
UIコンポーネント(src/components/Todo.tsx)
import React from 'react';
import Button from '@/components/common/parts/Button';
import useTodo from '@/lib/useTodo';
const Todo = (): JSX.Element => {
const {
inputValue,
tasks,
handleInputChange,
handleTaskAdd,
handleTaskComplete,
handleTaskDelete,
} = useTodo();
return (
<div className="mx-auto mt-10 max-w-4xl">
<div className="flex justify-center">
<div>
{/* 入力フォーム */}
<div>
<input
type="text"
placeholder="タスクを入力"
className="mb-5 rounded-md border-solid text-center"
value={inputValue}
onChange={handleInputChange}
/>
<Button variant="primary" label="追加" onClick={handleTaskAdd} />
</div>
{/* タスク一覧 */}
<div>
<ul>
{tasks.map((task, index) => {
return (
<li key={index} className="mb-3 flex items-center justify-between">
<span className={task.completed ? 'text-gray-500 line-through' : ''}>
{task.label}
</span>
<div className="flex gap-2">
<Button
variant="secondary"
label={task.completed ? '未完了' : '完了'}
onClick={() => handleTaskComplete(index)}
/>
<Button
variant="primary"
label="削除"
onClick={() => handleTaskDelete(index)}
/>
</div>
</li>
);
})}
</ul>
</div>
</div>
</div>
</div>
);
};
export default Todo;
UIコンポーネントファイルでは、画面表示のロジックを実装します。
-
useTodoフックを呼び出してロジックを取得 - JSXのみを記述(ステート管理やロジックは含まない)
- 再利用可能なコンポーネントとして実装
このファイルは、見た目とユーザーインタラクションを担当します。
ページコンポーネント(src/pages/practice/17.tsx)
// 方針
// タスクの追加、完了、削除機能を実装
// ステート管理とmap、filterメソッドを使った配列操作を学ぶ
// 以下の様に分割
// src/types/UseTodo.ts >> Todoロジックコンポーネントが返す戻り値の型を定義する
// src/lib/useTodo.ts >> Todoロジックを書く
// src/components/Todo.tsx >> TodoUIを書き、ロジックのimportをし合体する(exportし,17.tsxで利用する)
import { NextPage } from 'next';
import Todo from '@/components/Todo';
const Page: NextPage = () => {
return <Todo />;
};
export default Page;
ページコンポーネントファイルでは、ページ全体の構造を定義します。
-
Todoコンポーネントを呼び出すだけのシンプルな構成 - Next.jsのページルーティングに対応
- ページ固有の設定(メタデータなど)を追加する場所
このファイルは、ページ全体の構造を担当します。
ファイル分割のメリット
このようにファイルを分割することで、以下のようなメリットが得られます:
- 型定義の一元管理: 型定義を一箇所にまとめることで、型の変更が容易になる
-
ロジックの再利用:
useTodoフックを他のコンポーネントでも使用できる -
UIコンポーネントの再利用:
Todoコンポーネントを他のページでも使用できる - 責任の分離: 各ファイルが明確な役割を持ち、コードの理解が容易になる
この分割パターンは、Reactアプリケーション開発における標準的なアーキテクチャです。ぜひ実践してみてください!
まとめ
この記事では、ReactでTODOアプリを作成しながら、以下の重要な概念を学びました:
ステート設計で学んだこと
- 何をステートとして管理すべきか: 入力値とタスク一覧の2つを管理
- オブジェクト配列の管理: 単純な配列ではなく、オブジェクトの配列を管理することで、より複雑な状態を表現できる
- 型定義の重要性: TypeScriptで型を定義することで、データ構造を明確にし、バグを防ぐ
配列操作で学んだこと
-
mapメソッドによる更新: 配列内の特定のオブジェクトのプロパティを更新する方法 - スプレッド構文: オブジェクトの部分更新をイミュータブルに実現する方法
-
filterメソッドによる削除: 配列から特定の要素を取り除く方法 - 関数型更新: 常に最新のステートを参照するためのパターン
ステートとUIの連携で学んだこと
- 三項演算子による条件付きスタイリング: ステートに基づいてUIの見た目を動的に変更する方法
- 動的なラベル変更: ステートに応じてボタンのラベルを切り替える方法
- 宣言的なUI: ステートが変更されると自動的にUIも更新される仕組み
これらの概念は、ReactでインタラクティブなUIを構築する上で基礎となるものです。ぜひ実際にコードを書いて、動作を確認してみてください!


