1. はじめに
「挫折しないReactの教科書」を読みながらシンプルなToDoアプリを作りました。タスクの一覧表示・追加・バリデーション・完了切り替え・削除・絞り込み表示・コンポーネント分割まで、実際に手を動かして理解したことを整理しています。
2. タスクの一覧を表示する
まずタスクの一覧を画面に表示できるようにしました。
2.1 todosのstateを定義する
タスクはオブジェクトの配列として useState で管理しました。各タスクのオブジェクトは以下の3つのプロパティを持っています。
-
id:タスクを識別するための重複しない番号 -
text:タスクの内容 -
completed:タスクが完了しているかどうかを表す真偽値(trueなら完了、falseなら未完了)
タスク一覧のstateを定義して表示するコードです。
import { useState } from 'react';
import './App.css';
function App() {
// タスクの一覧を管理するstateを定義
const [todos, setTodos] = useState([
{ id: 1, text: '第3章の復習をする', completed: false },
{ id: 2, text: 'ToDoアプリを完成させる', completed: false },
]);
return (
<div className="app">
<h1>ToDoアプリ</h1>
{/* todo-listというクラス名のリストを作成 */}
<ul className="todo-list">
{/* map()でstate内のタスクを一件ずつ取り出して表示 */}
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
);
}
export default App;
2.2 map()のreturnを省略した書き方
map() の中でアロー関数を使うとき、{} の代わりに () を使うことで return を省略できると理解しました。
// returnを省略しない書き方
{todos.map((todo) => {
return (
<li key={todo.id}>{todo.text}</li>
);
})}
// returnを省略した書き方(今回のコード)
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
どちらの書き方も同じ動作をしますが、省略形がよく使われるみたいです。
3. タスクを追加できるようにする
画面から新しいタスクを追加できる機能を実装しました。
3.1 入力フォームとstateの追加
入力フォームと、入力値を管理する input stateを追加しました。
入力フォームを追加したコードです。
import { useState } from 'react';
import './App.css';
function App() {
const [todos, setTodos] = useState([
{ id: 1, text: '第3章の復習をする', completed: false },
{ id: 2, text: 'ToDoアプリを完成させる', completed: false },
]);
// フォームの入力値を管理するstateを定義
const [input, setInput] = useState('');
return (
<div className="app">
<h1>ToDoアプリ</h1>
{/* 入力フォームを作成 */}
<div className="todo-form">
<label htmlFor="todo-input" className="form-label">
新しいタスク
</label>
{/* 入力エリアを作成 */}
<input
id="todo-input"
type="text"
{/* input stateの値を入力エリアに表示 */}
value={input}
{/* 入力エリアに入力された内容をinput stateに反映 */}
onChange={(e) => setInput(e.target.value)}
placeholder="新しいToDoを入力"
className="todo-input"
/>
<button type="submit" className="add-button">
追加
</button>
</div>
<ul className="todo-list">
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
);
}
export default App;
value={input} で入力エリアに input stateの値を表示し、onChange={(e) => setInput(e.target.value)} で入力されるたびにstateを更新することで、入力エリアとstateが常に連動する仕組みになっていると理解しました。
3.2 タスク追加機能の実装
「追加」ボタンをクリックしたときに新しいタスクを todos stateに追加する関数を実装しました。
タスク追加関数を追加したコードです。
function App() {
const [todos, setTodos] = useState([...]);
const [input, setInput] = useState('');
// 新しいタスクを作成する関数
const addTodo = () => {
// 新しいタスクをtodos stateに追加
setTodos([
...todos,
{
// Date.now()で現在の時刻を数値で取得し一意なIDとして使用
id: Date.now(),
// input stateに入っているユーザーが入力した内容
text: input,
// 新しく作成されたタスクは未完了なのでfalse
completed: false,
},
]);
// 入力エリアをリセット
setInput('');
};
return (
<div className="app">
{/* 省略 */}
{/* 「追加」ボタンを押した際にaddTodoを実行するように */}
<button type="submit" className="add-button" onClick={addTodo}>
追加
</button>
{/* 省略 */}
</div>
);
}
新しいタスクを todos stateに追加する部分にはスプレッド構文を使っています。
setTodos([...todos, { id: Date.now(), text: input, completed: false }]);
...todos で現在の todos state内のすべてのタスクを展開し、その後に新しいタスクのオブジェクトを追加することで、既存のタスクを保持しつつ新しいタスクを末尾に追加した新しい配列を作成していると理解しました。
Date.now() は現在の時刻をミリ秒単位の数値で返すJavaScriptの関数みたいです。ミリ秒はとても細かい単位なので、タスクを作成するたびに異なる値が得られるため、他のタスクと重ならない一意なIDとして使用できると理解しました。
3.3 空のタスクを作れないようにする
入力エリアに何も入力せずに「追加」ボタンを押すと内容のないタスクが作成されてしまいます。そこで入力内容のバリデーションを追加しました。
バリデーションを追加したコードです。
function App() {
const [todos, setTodos] = useState([...]);
const [input, setInput] = useState('');
// エラーメッセージを管理するstateを定義
const [error, setError] = useState('');
const addTodo = () => {
// 入力エリアの値をtrim()で空白を削除
const text = input.trim();
// 入力エリアの値が空の場合はエラーメッセージを表示し、処理を終了する
if (!text) {
setError('タスクを入力してください');
return;
}
setTodos([
...todos,
{
id: Date.now(),
// trim()で空白を削除したテキストを使うように
text: text,
completed: false,
},
]);
setInput('');
// タスクが作成できた場合はエラーメッセージをリセット
setError('');
};
return (
<div className="app">
{/* 省略 */}
{/* エラーメッセージを表示 */}
{error && <p className="error-message">{error}</p>}
{/* 省略 */}
</div>
);
}
trim() は文字列の前後にある空白文字(スペース・タブ・改行など)を削除するJavaScriptのメソッドみたいです。
trim() を使わない場合、スペースだけ入力して「追加」ボタンを押したとき、input stateには ' '(スペース3つ)が入っているため空文字として判定されません。
しかし trim() を使うことで ' '.trim() が空文字になるため、スペースだけの入力も防げると理解しました。
JavaScriptでは空文字は false として扱われます。そのため !text は text が空文字の場合に true になります。また return が実行されると、その時点で関数の処理が終了するため、それ以降のタスクを追加する処理は実行されません。
4. タスクの完了状態を切り替える
チェックボックスをクリックするとタスクの完了・未完了を切り替える機能を実装しました。
4.1 toggleTodo関数を作る
タスクの completed を切り替える関数を作りました。
完了状態の切り替え機能を追加したコードです。
function App() {
const [todos, setTodos] = useState([...]);
const [input, setInput] = useState('');
const [error, setError] = useState('');
const addTodo = () => { /* 省略 */ };
// タスクのcompletedを切り替える関数
const toggleTodo = (id) => {
// 引数に渡されたidと一致するタスクのcompletedを切り替える
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
return (
<div className="app">
{/* 省略 */}
<ul className="todo-list">
{todos.map((todo) => (
{/* 完了状態に応じてクラスを追加 */}
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
{/* チェックボックスを追加 */}
<input
type="checkbox"
{/* チェックボックスにcompletedの内容が反映されるように */}
checked={todo.completed}
{/* チェックボックスをクリック時に完了状態を切り替える関数を実行 */}
onChange={() => toggleTodo(todo.id)}
/>
{/* テキストをspan要素で囲んでCSSが当たるように */}
<span>{todo.text}</span>
</li>
))}
</ul>
</div>
);
}
toggleTodo 関数の中心となる処理を詳しく見てみましょう。
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
todos.map(...) ですべてのタスクを一つずつ処理して新しい配列を作成し、setTodos(...) で作成した新しい配列でstateを更新しています。各タスクに対して三項演算子で「取り出したタスクのIDが引数に渡された id と一致する場合は完了状態を切り替えた新しいタスクを返し、一致しない場合はそのままのタスクを返す」という処理を行っていると理解しました。
条件が true の場合に返される { ...todo, completed: !todo.completed } では、スプレッド構文で現在のタスクのすべてのプロパティを展開し、その後に completed: !todo.completed を書くことで completed だけを上書きしています。! は否定を表す演算子で、true なら false に、false なら true に書き換えると理解しました。
5. タスクを削除する
タスクの右側に「削除」ボタンを追加し、クリックすると対応したタスクが削除される機能を実装しました。
タスク削除機能を追加したコードです。
function App() {
// 省略
// タスクを削除する関数
const deleteTodo = (id) => {
// filter()を使い、指定されたidを除外した配列を作成し、stateを更新
setTodos(todos.filter((todo) => todo.id !== id));
};
return (
<div className="app">
{/* 省略 */}
<ul className="todo-list">
{todos.map((todo) => (
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
{/* 「削除」ボタンを追加し、クリック時にタスクを削除する関数を実行するように */}
<button onClick={() => deleteTodo(todo.id)}>削除</button>
</li>
))}
</ul>
</div>
);
}
deleteTodo 関数では filter() を使って「削除したいID(引数の id)と一致しないタスク」だけを集めた新しい配列を作成し、stateを更新しています。todo.id !== id という条件で、削除したいタスク以外のタスクだけが残った新しい配列が作られると理解しました。
6. コンポーネントを分割する
タスク一覧部分を別のコンポーネントとして切り出しました。src/components/TodoList.jsx というファイルを作成し、タスク一覧を表示するJSXを移動しています。
TodoListコンポーネントのコードです。
// propsでタスクの一覧とチェックボックスやボタンが押下時の処理を受け取る
function TodoList({ todos, onToggle, onDelete }) {
return (
<ul className="todo-list">
{todos.map((todo) => (
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
{/* propsで受け取った関数を実行 */}
<button onClick={() => onDelete(todo.id)}>削除</button>
</li>
))}
</ul>
);
}
export default TodoList;
TodoList は todos・onToggle・onDelete の3つのpropsを分割代入で受け取っています。子コンポーネント(TodoList)で「削除」ボタンがクリックされてイベントが発生すると、親コンポーネント(App)から渡された関数が実行され、親コンポーネントのstateが更新されると理解しました。
App.jsx側では TodoList をインポートして以下のように使っています。
import TodoList from './components/TodoList';
// ...
<TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
7. 状態でタスクを絞り込んで表示する
「未完了」「完了」「すべて」のボタンで表示するタスクを絞り込む機能を実装しました。
7.1 絞り込み状態を管理するstateを作る
どの状態で絞り込んでいるかを管理する filter stateを追加しました。初期値は 'active'(未完了のみ表示)です。
絞り込みボタンと絞り込み関数を追加したコードです。
function App() {
const [todos, setTodos] = useState([...]);
const [input, setInput] = useState('');
const [error, setError] = useState('');
// どの状態で絞り込んでいるかを管理するstateを定義
const [filter, setFilter] = useState('active');
// filter stateに合わせて絞り込んだタスクを返す関数
const getFilteredTodos = () => {
// activeの場合は未完了のタスクのみを返す
if (filter === 'active') return todos.filter((todo) => !todo.completed);
// completedの場合は完了したタスクのみを返す
if (filter === 'completed') return todos.filter((todo) => todo.completed);
// どちらの条件にも当てはまらない場合はすべてのタスクをそのまま返す
return todos;
};
// 絞り込んだタスクを取得
const filteredTodos = getFilteredTodos();
// 未完了と完了のタスクの数を取得
const activeCount = todos.filter((todo) => !todo.completed).length;
const completedCount = todos.filter((todo) => todo.completed).length;
return (
<div className="app">
<h1>ToDoアプリ</h1>
{/* 省略 */}
{/* 絞り込みボタンを作成 */}
<div className="filter-buttons">
<button
{/* クリック時に未完了のタスクのみで絞り込むようfilter stateを更新 */}
onClick={() => setFilter('active')}
{/* 未完了のタスクで絞り込んでいる場合はボタンの見た目を変える */}
className={filter === 'active' ? 'active' : ''}
>
{/* 未完了のタスクの数を表示 */}
未完了 ({activeCount})
</button>
<button
onClick={() => setFilter('completed')}
className={filter === 'completed' ? 'active' : ''}
>
{/* 完了のタスクの数を表示 */}
完了 ({completedCount})
</button>
<button
onClick={() => setFilter('all')}
className={filter === 'all' ? 'active' : ''}
>
{/* すべてのタスクの数を表示 */}
すべて ({todos.length})
</button>
</div>
{/* todosではなく絞り込んだfilteredTodosをTodoListに渡す */}
<TodoList todos={filteredTodos} onToggle={toggleTodo} onDelete={deleteTodo} />
</div>
);
}
const filteredTodos = getFilteredTodos(); の行はAppコンポーネント内に直接書かれているため、コンポーネントが再描画されるたびにこの行も実行されると理解しました。ユーザーがボタンをクリックすると filter stateが更新されてコンポーネント全体が再描画され、再描画の際に getFilteredTodos() が実行されて更新された filter の条件に合わせて絞り込まれたタスクが返されるという流れになっていると理解しました。
7.2 タスクがない場合の表示を追加する
タスクが一つもなかった場合に「タスクがありません」というメッセージを表示するようにしました。
タスクがない場合のメッセージをTodoList.jsxに追加したコードです。
function TodoList({ todos, onToggle, onDelete }) {
// タスクが一つもなかった場合の表示
if (todos.length === 0) {
return <p className="empty-state">タスクがありません</p>;
}
return (
<ul className="todo-list">
{todos.map((todo) => (
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>削除</button>
</li>
))}
</ul>
);
}
export default TodoList;
todos.length === 0 の条件でタスクが一つもない場合をチェックしています。この条件が true の場合、return が実行されてその時点で関数の処理が終了するため、その後の <ul> タグを返す処理は実行されないと理解しました。
8. 最終的なコード
実装した全機能をまとめた最終的なコードです。
8.1 App.jsx
App.jsxの最終コードです。
import { useState } from 'react';
import './App.css';
import TodoList from './components/TodoList';
function App() {
// タスクの一覧を管理するstateを定義
const [todos, setTodos] = useState([
{ id: 1, text: '第3章の復習をする', completed: false },
{ id: 2, text: 'ToDoアプリを完成させる', completed: false },
]);
// フォームの入力値を管理するstate
const [input, setInput] = useState('');
// エラーメッセージを管理するstate
const [error, setError] = useState('');
// どの状態で絞り込んでいるかを管理するstate
const [filter, setFilter] = useState('active');
// 新しいタスクを作成する関数
const addTodo = () => {
// 入力エリアの値をtrim()で空白を削除
const text = input.trim();
// 入力エリアの値が空の場合はエラーメッセージを表示し、処理を終了する
if (!text) {
setError('タスクを入力してください');
return;
}
// 新しいタスクをtodos stateに追加
setTodos([
...todos,
{
// Date.now()で現在の時刻を数値で取得し一意なIDとして使用
id: Date.now(),
// trim()で空白を削除したテキストを使う
text: text,
// 新しく作成されたタスクは未完了なのでfalse
completed: false,
},
]);
// 入力エリアをリセット
setInput('');
// タスクが作成できた場合はエラーメッセージをリセット
setError('');
};
// タスクのcompletedを切り替える関数
const toggleTodo = (id) => {
// 引数に渡されたidと一致するタスクのcompletedを切り替える
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
// タスクを削除する関数
const deleteTodo = (id) => {
// filter()を使い、指定されたidを除外した配列を作成し、stateを更新
setTodos(todos.filter((todo) => todo.id !== id));
};
// filter stateに合わせて絞り込んだタスクを返す関数
const getFilteredTodos = () => {
// activeの場合は未完了のタスクのみを返す
if (filter === 'active') return todos.filter((todo) => !todo.completed);
// completedの場合は完了したタスクのみを返す
if (filter === 'completed') return todos.filter((todo) => todo.completed);
// どちらの条件にも当てはまらない場合はすべてのタスクをそのまま返す
return todos;
};
// 絞り込んだタスクを取得
const filteredTodos = getFilteredTodos();
// 未完了のタスクの数を取得
const activeCount = todos.filter((todo) => !todo.completed).length;
// 完了したタスクの数を取得
const completedCount = todos.filter((todo) => todo.completed).length;
return (
<div className="app">
<h1>ToDoアプリ</h1>
{/* 入力フォームを作成 */}
<div className="todo-form">
<label htmlFor="todo-input" className="form-label">
新しいタスク
</label>
{/* 入力エリアを作成 */}
<input
id="todo-input"
type="text"
// input stateの値を入力エリアに表示
value={input}
// 入力エリアに入力された内容をinput stateに反映
onChange={(e) => setInput(e.target.value)}
placeholder="新しいToDoを入力"
className="todo-input"
/>
{/* 「追加」ボタンを押した際にaddTodoを実行するように */}
<button type="submit" className="add-button" onClick={addTodo}>
追加
</button>
{/* エラーメッセージを表示 */}
{error && <p className="error-message">{error}</p>}
</div>
{/* 絞り込みボタンを作成 */}
<div className="filter-buttons">
<button
// クリック時に未完了のタスクのみで絞り込むようfilter stateを更新
onClick={() => setFilter('active')}
// 未完了のタスクで絞り込んでいる場合はボタンの見た目を変える
className={filter === 'active' ? 'active' : ''}
>
未完了 ({activeCount})
</button>
<button
onClick={() => setFilter('completed')}
className={filter === 'completed' ? 'active' : ''}
>
完了 ({completedCount})
</button>
<button
onClick={() => setFilter('all')}
className={filter === 'all' ? 'active' : ''}
>
すべて ({todos.length})
</button>
</div>
{/* todosではなく絞り込んだfilteredTodosをTodoListに渡す */}
<TodoList
todos={filteredTodos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
</div>
);
}
export default App;
8.2 src/components/TodoList.jsx
TodoList.jsxの最終コードです。
// propsでタスクの一覧とチェックボックスやボタンが押下時の処理を受け取る
function TodoList({ todos, onToggle, onDelete }) {
// タスクが一つもなかった場合の表示
if (todos.length === 0) {
return <p className="empty-state">タスクがありません</p>;
}
return (
<ul className="todo-list">
{todos.map((todo) => (
// 完了状態に応じてクラスを追加
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
{/* チェックボックスを追加 */}
<input
type="checkbox"
// チェックボックスにcompletedの内容が反映されるように
checked={todo.completed}
// チェックボックスをクリック時に完了状態を切り替える関数を実行
onChange={() => onToggle(todo.id)}
/>
{/* テキストをspan要素で囲んでCSSが当たるように */}
<span>{todo.text}</span>
{/* 「削除」ボタンを追加し、クリック時にタスクを削除する関数を実行するように */}
<button onClick={() => onDelete(todo.id)}>削除</button>
</li>
))}
</ul>
);
}
export default TodoList;
まとめ
- タスクは
id・text・completedの3つのプロパティを持つオブジェクトの配列としてuseStateで管理すると理解しました - スプレッド構文
...todosで既存タスクを展開しつつ末尾に新タスクを追加することで、stateを直接変更せずに更新できると理解しました -
trim()で前後の空白を削除してから空文字チェックすることで、スペースだけの入力も防げると理解しました -
map()・スプレッド構文・三項演算子を組み合わせて、特定タスクのcompletedだけを切り替えた新しい配列を作成してstateを更新できると理解しました -
filter()で「削除したいID以外のタスク」だけを残した新しい配列を作成してstateを更新することでタスクの削除を実現できると理解しました - 絞り込み関数をコンポーネント内に直接書くことで、再描画のたびに自動再実行されるため
useEffectを使わずに絞り込みを実現できると理解しました - stateの更新はデータを直接書き換えてはいけないため、スプレッド構文や
filter()で元のデータを変更した新しい配列を作ってstateに入れる必要があると理解しました
