React Hooks と TypeScript で簡単 TODO アプリ
1. はじめに
対象とする読者
以下のような人を読者として想定しています。
- ある程度 JavaScript を習得している人
- React 公式チュートリアルを終えたものの、次のステップを探しているような人
- Node.js をインストール済みである人
- Git Bash もしくは何らかの UNIX シェルの操作をある程度習得している人
コードエディタには、 Visual Studio Code(以下、VSCode)を利用します。VSCode に備わる機能の利用を前提とした記述もありますので、インストールしておくことをおすすめします。
目標とする Todo アプリ
- タスク (Todo) を既済・未済・削除済みなどの状態によってフィルタリングできる
- 登録済みタスクを編集できる
- 削除済みアイテムを「ごみ箱」フィルタから完全に削除できる
他のおすすめのドキュメント
2. 開発環境の準備
Vite.js で React プロジェクトを作成する
Vite.js(以下、Vite)を利用して React + TypeScript のプロジェクトを作成します。
# Node.js に同梱されている npm コマンドを利用する場合
% npm create vite
# パッケージマネージャーに Yarn を利用している場合
% yarn create vite
- そのまま Enter キーを押します。
プロジェクトに名前をつける
-
react
を選択します。
フレームワークの選択
-
react-ts
を選択します。
JavsScript または TypeScript の選択
% npm create vite
✔ Project name: … vite-project
✔ Select a framework: › react
✔ Select a variant: › react-ts
Scaffolding project in /Users/zenn/vite-project...
Done. Now run:
cd vite-project
npm install
npm run dev
Vite の指示に従い、さっそくこのプロジェクトを起動してみましょう。
% cd vite-project
% npm install
% npm run dev
ブラウザで http://localhost:5173 にアクセスすると React アプリが表示されます。
React デベロッパーツールの準備
Google Chrome、Mozilla Firefox、または Microsoft Edge には React Developer Tools という拡張機能がそれぞれ用意されています。
これは、React コンポーネントの状態をブラウザの開発者ツール画面に表示してくれる拡張機能です。必ずインストールしておきましょう。
開発者ツールを表示するには、キーボードから Ctrl + Shift + I
を打鍵します(Windows 版 Chrome の場合)。 Components
タブからコンポーネント名(ここでは App
)を選択すると React コンポーネントの状態を確認できます。
ホットリロード機能の確認
VSCode(もしくは何らかのコードエディタ)で todo
フォルダを開き、src/App.tsx
を編集してみましょう。
function App() {
const [count, setCount] = useState(0)
return (
<div className="App">
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" className="logo" alt="Vite logo" />
</a>
<a href="https://reactjs.org" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
- <h1>Vite + React</h1>
+ <h1>Todo App</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
// ~ snip ~
ファイルを保存するとブラウザ画面へ変更が自動的に反映されています。
中央のメッセージが 'Todo App' に変わっている
3. React コンポーネントの作成
todo(タスク)を入力するためのフォームを持った、関数コンポーネントを作成するところから始めましょう。
src
ディレクトリ内の main.tsx
と App.tsx
の2つを以下のファイルへ置き換えてください。
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
export const App = () => {
return (
<div>
<form onSubmit={(e) => e.preventDefault()}>
<input type="text" value="" onChange={(e) => e.preventDefault()} />
<input
type="submit"
value="追加"
onSubmit={(e) => e.preventDefault()}
/>
</form>
</div>
);
};
以降、この関数コンポーネント App
をベースとして Todo アプリを作成していきます。
いまのところ onSubmit
や onChange
などのイベントリスナーでは、イベントが発生しても preventDefault() してしまっているので特に何も起きません。
4. フォームに入力された文字列を状態 (=state) として保持する
useState フックの構文
const [foo, setFoo] = React.useState('bar');
-
useState
:- 引数となるのはステートの初期値です
- 現在のステート
foo
と、それを更新するための関数setFoo
とをペアにして返します
-
foo
: 現在のステートの値です -
setFoo
: ステートを更新するメソッドです-
'set' + ステート
(ロワー・キャメルケース)とするのが通例です -
setFoo('boo')
のようにしてステートを更新します
-
text ステートを作成
// React から useState フックをインポート
import { useState } from 'react';
export const App = () => {
/**
* text = ステートの値
* setText = ステートの値を更新するメソッド
* useState の引数 = ステートの初期値 (=空の文字列)
*/
const [text, setText] = useState('');
return (
<div>
<form onSubmit={(e) => e.preventDefault()}>
{/*
入力中テキストの値を text ステートが
持っているのでそれを value として表示
onChange イベント(=入力テキストの変化)を
text ステートに反映する
*/}
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<input
type="submit"
value="追加"
onSubmit={(e) => e.preventDefault()}
/>
</form>
</div>
);
};
5. Todo(タスク)の仕様を考える (その 1)
ひとつの todo
をオブジェクトとすると、そのオブジェクトにはタスクの内容を保持するプロパティ(変数)が必要となります。ここでは、これを value
プロパティとします。
入力フォームに入力されたテキスト文字列が代入されるので、この value
プロパティは string 型
となります。
これから作成される複数の todo
のひな型として Todo 型オブジェクト
の型エイリアスを定義しましょう。
type Todo = {
value: string;
};
export const App = () => {
ステートとして保持しておくタスクたち(todos 複数)は Todo 型オブジェクトの配列
となります。
export const App = () => {
const [text, setText] = useState('');
// 追加
const [todos, setTodos] = useState<Todo[]>([]);
return (
useState<>
の中に型を指定しておくと、これと型が異なる値をステートに代入できなくなるため、ステートの型安全性が常に保証されます。
6. 配列ステートの操作には要注意
前章で作成した Todos ステートは Todo 型オブジェクトの配列 ですが、配列のステートを操作しなければならない場合、その配列を直接触ってはいけません。
その理由は、「ステートのイミュータビリティ(immutability, 不変性)が保持できなくなる」からですが、これを詳しく見ていきましょう。
イミュータブルな操作とは?
イミュータブルな操作とは、その操作の対象となった元の値を不変(=イミュータブル)に保つ操作のことです。
const array1 = [0, 1, 2];
const array2 = [0, 1, 2];
array1.push(3);
[...array2, 3];
上の例での下 2 行では、どちらもそれぞれの元の配列に 3
という要素を追加しています。
では、元の配列の値はどうなったでしょうか?
console.table(array1);
0
1
2
3
console.table(array2);
0
1
2
Array.push
メソッドが元の配列を 変更(=ミューテート) してしまったのに対し、スプレッド構文
を使った要素の追加では元の配列の イミュータビリティ(=不変性) が保たれています。
ここでの Array.push
メソッドが「ミュータブルな操作」、スプレッド構文
が「イミュータブルな操作」です。
なぜ React ではイミュータブルな操作が必要なのか?
では、なぜ React ではイミュータブルな操作が必要とされるのでしょうか?
それは、React がコンポーネントの変化をオブジェクトの 同一性(差分) チェックで検知しているためです。
ミュータブルな操作をしてしまうとコピー元の情報も変更されてしまうため、変更前と変更後の差分を React が検知できなくなってしまいます。
一方、イミュータブルな操作では変更前と変更後の情報をそれぞれ参照しているので、React は差分を検知できます。
ここでの例で言うと、todos
ステートへの以下のような操作はいけません。
// ❌ Bad code
setTodos(todos.push({ value: 'new task' }));
なぜなら、Array.prototype.push()
や Array.prototype.unshift()
は破壊的メソッドなので todos
ステートを直接ミューテート(mutate, 書き換え)してしまうからです。
配列のステートを操作する場合には、 いったんそのコピーに対して変更を加え、その変更後のコピーでステートを更新します。
const todos = [{ value: '最初のタスク' }];
// todos ステート配列をコピー
const newTodos = todos.slice();
// コピーした配列へ Todo 型オブジェクトの要素を追加
newTodos.unshift({ value: '新しいタスク' });
// それぞれの配列の内容を確認
console.log('=== old todos ===');
console.log(JSON.stringify(todos));
/**
*
* 結果:
* === old todos ===
* [{"value":"最初のタスク"}]
*
**/
console.log('=== new todos ===');
console.log(JSON.stringify(newTodos));
/**
*
* 結果:
* === new todos ===
* [{"value":"新しいタスク"},{"value":"最初のタスク"}]
*
* 元の配列 (= todos ) に影響を与えることなく、
* コピーした配列 (= newTodos ) へ要素が追加されている
*
*/
// 新しい配列で todos ステートを更新
setTodos(newTodos);
こうすることで元の配列(=更新前のステート)と新しいステートの差分を React が検知できるようになります。
参考記事
7. ステートを更新するコールバック関数を作成する
コールバック関数の作成
それでは todos
ステートを更新(=新しいタスクの追加)していきましょう。
ステートを更新するコールバック関数を作成します。
const [todos, setTodos] = useState<Todo[]>([]);
// todos ステートを更新する関数
const handleOnSubmit = () => {
// 何も入力されていなかったらリターン
if (!text) return;
// 新しい Todo を作成
const newTodo: Todo = {
value: text,
};
/**
* スプレッド構文を用いて todos ステートのコピーへ newTodo を追加する
* 以下と同義
*
* const oldTodos = todos.slice();
* oldTodos.unshift(newTodo);
* setTodos(oldTodos);
*
**/
setTodos([newTodo, ...todos]);
// フォームへの入力をクリアする
setText('');
};
コールバック関数をイベントに割り当てる
上のコールバック関数を onSubmit
イベントへ紐付けましょう。
注意点:
- コールバックとして渡すのは
() => hoge()
もしくはhoge
の関数そのものです -
hoge()
のみだと即時に実行されてしまうので用をなしません
<form>
タグの中でいったん e.preventDefault()
しているのは Enter キー打鍵でページそのものがリロードされてしまうのを防ぐためです。
return (
<div>
{/* コールバックとして () => handleOnSubmit() を渡す */}
<form
onSubmit={(e) => {
e.preventDefault();
handleOnSubmit();
}}
>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
{/* 上に同じ */}
<input type="submit" value="追加" onSubmit={handleOnSubmit} />
</form>
</div>
onSubmit
イベントが発火するとコールバック関数が実行され、todos
ステートを更新(=新しいタスクを追加)します。
フォームへ入力して submit(Enter キー打鍵)すれば、ステート (todos
) が更新されていることを開発者ツールで確認できます。
text
ステート向けのコールバック関数も用意する
上の例で要領を得ましたので、text ステート
についても JSX の中で直接 setText
していた部分をコールバック関数 handleOnChange() として書き出しましょう。
コールバック関数として書き出すことで、のちのコンポーネント間での props
の受け渡しが容易になります。
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
<input type="text" value={text} onChange={(e) => handleOnChange(e)} />
イベントの型を調べる
イベントの型がわからない時は、VSCode であればイベント上でマウスカーソルを hover させるとポップアップが表示されます。
この章のソースコード全文
App.tsx
import { useState } from 'react';
type Todo = {
value: string;
};
export const App = () => {
const [text, setText] = useState('');
const [todos, setTodos] = useState<Todo[]>([]);
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleOnSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
};
setTodos([newTodo, ...todos]);
setText('');
};
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
handleOnSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleOnChange(e)} />
<input type="submit" value="追加" onSubmit={handleOnSubmit} />
</form>
</div>
);
};
8. todos ステートを展開してページに表示する
todos
ステート配列を Array.map()
メソッドで展開する
todos
ステートを展開し、タスク一覧としてページに表示します。
具体的には、todos (=配列)
を非破壊メソッドである Array.prototype.map() を使って <li></li>
タグへ展開します。
Array.map()
メソッドは、与えられた関数を配列のすべての要素に対して呼び出し、その結果からなる新しい配列を生成します。
// 例:
const newItems = items.map((item) => {
return item;
});
/** for 文で同義を書いた場合 */
const newItems = [];
for (let i = 0; i < items.length; i++) {
newItems.push(items[i]);
}
<div>
<form onSubmit={(e) => handleOnSubmit(e)}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<input type="submit" value="追加" onSubmit={(e) => handleOnSubmit(e)} />
</form>
<ul>
{todos.map((todo) => {
return <li>{todo.value}</li>;
})}
</ul>
</div>
ただし、これだけでは各 <li>
に key プロパティが設定されていないため、以下のような警告が表示されてしまいます。
チュートリアル:React の導入 - key を選ぶ(公式)
key
の重要性
リストをレンダーするときの なぜリストの各項目に key
プロパティが必要となるのでしょうか?
React はリストをレンダーする際、どのアイテムが変更になったのか特定できる必要があります。リストのアイテムは追加された可能性も、削除された可能性も、並び替えられた可能性も、中身自体が変更になった可能性もあるからです。
変更・追加・削除・並び替えを検知するためには、リストの各項目を特定する一意な識別子が必要です。
この一意な識別子こそが key
プロパティであり、上の警告は『各項目を特定できないため、リストに変更が加えられても正しく再レンダーできない可能性があります』という意味で表示されているのです。
次章では、この key
プロパティを各項目へ与えるため、Todo 型オブジェクト
の仕様について再考します。
9. Todo(タスク) の仕様を考える (その 2)
前章で見た通り、todos
ステート配列をリストとして展開するためには、配列の各要素へその識別子を持たせる必要があります。
配列の各要素、つまり Todo
型のタスクそれぞれに一意な key
を持たせる必要が生じたため、Todo
型そのものを拡張しなければなりません。
ここでは、id
プロパティとして一意な数字 (number 型
) を持たせることにします。
また、一意であるはずの識別子が書き換えられてはならないため、readonly(読み取り専用) のプロパティとします。
type Todo = {
value: string;
+ readonly id: number;
};
Todo
型オブジェクトには id
プロパティの指定が必須となったため、handleOnSubmit()
メソッドを更新しなければいけません。
const handleOnSubmit = (
e: React.FormEvent<HTMLFormElement | HTMLInputElement>
) => {
e.preventDefault();
if (!text) return;
const newTodo: Todo = {
value: text,
/**
* Todo 型オブジェクトの型定義が更新されたため、
* number 型の id プロパティの存在が必須になった
*/
id: new Date().getTime(),
};
setTodos([newTodo, ...todos]);
setText('');
};
これでそれぞれのタスクが一意なプロパティを持つようになったので、これを key
プロパティへ適用しましょう。
<li></li>
タグに key (=id)
を付加します。
<ul>
{todos.map((todo) => {
return <li key={todo.id}>{todo.value}</li>;
})}
</ul>
この章のソースコード全文
この章のソースコード全文:
import { useState } from 'react';
type Todo = {
value: string;
readonly id: number;
};
export const App = () => {
const [text, setText] = useState('');
const [todos, setTodos] = useState<Todo[]>([]);
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleOnSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
};
setTodos([newTodo, ...todos]);
setText('');
};
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
handleOnSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleOnChange(e)} />
<input type="submit" value="追加" onSubmit={handleOnSubmit} />
</form>
<ul>
{todos.map((todo) => {
return <li key={todo.id}>{todo.value}</li>;
})}
</ul>
</div>
);
};
10. 登録済みの todo を編集可能にする
todo
を入力フォーム化する
各 すでに登録済みの todo を編集可能にするため、<li></li>
タグ内で展開される各項目の todo.value
を <input />
タグでラップします。
<ul>
{todos.map((todo) => {
return (
<li key={todo.id}>
<input
type="text"
value={todo.value}
onChange={(e) => e.preventDefault()}
/>
</li>
);
})}
</ul>
ここでも onChange
イベントではとりあえず e.preventDefault()
しているので入力しても何の変化も起きません。
登録済み todo が編集された時のコールバック関数を作成する
編集されたタスクの内容を適用し、そのタスクを古いものから新しいものへ入れ替えた状態に todos ステート
を更新しなければいけません。
ステートを更新するコールバック関数には以下の要件が求められます。
- どの
todo
が編集されたのか特定するため、そのtodo
のid
を引数として受け取る -
e.target.value
(onChange イベントの結果)を書き換え後のtodo.value
の値とするために第2引数として受け取る - 編集後の
todo
を含むTodo 型の配列
でtodos ステート
を書き換える
const handleOnEdit = (id: number, value: string) => {
/**
* 引数として渡された todo の id が一致する
* todos ステート(のコピー)内の todo の
* value プロパティを引数 value (= e.target.value) に書き換える
*/
const newTodos = todos.map((todo) => {
if (todo.id === id) {
todo.value = value;
}
return todo;
});
// todos ステートを更新
setTodos(newTodos);
};
Array.prototype.map() は、配列のコピーに変更を加えた結果からなる新しい配列を生成する非破壊的メソッドです。
上のコールバック関数を <input onChange={} />
イベントに紐付けます。
<ul>
{todos.map((todo) => {
return (
<li key={todo.id}>
<input
type="text"
value={todo.value}
onChange={(e) => handleOnEdit(todo.id, e.target.value)}
/>
</li>
);
})}
</ul>
ステートのイミュータビリティは保たれているか?
では、上の処理を行うことによってもステートのイミュータビリティ(immutability, 不変性)は保たれているのでしょうか?
結論から言うと、この手法ではイミュータビリティを保つことはできません。
上のコードの newTodos
配列を作成した後(かつ todos ステート
を更新する前)の todos ステート
の値を確認してみましょう。
const handleOnEdit = (id: number, value: string) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
todo.value = value;
}
return todo;
});
// todos ステートが書き換えられていないかチェック
console.log('=== Original todos ===');
todos.map((todo) => console.log(`id: ${todo.id}, value: ${todo.value}`));
setTodos(newTodos);
};
結果は以下のようになります。
setTodos(newTodos)
が実行される前に todos ステート
配列が直接ミューテートされてしまっています。
Array.prototype.map() は、「新しい配列を生成する非破壊的メソッド」であるはずなのに何故なのでしょうか?
11. 配列ステートの操作には要注意 (その 2)
シャロー(浅い)コピー
chap.07 ではスプレッド構文によって保たれたイミュータビリティが、前章の Array.prototype.map() メソッドでは保てませんでした。
なぜなら、配列の要素であるオブジェクトの中で入れ子になっているプロパティ(= todos ステート
配列を構成するそれぞれの Todo 型オブジェクト
の value
プロパティ)のイミュータビリティを維持するには、シャローコピー(浅いコピー) では不十分だからです。
シャロー(浅い)コピーでは、1 段階目の要素のみ(ここでは Todos
ステート配列の各要素)がコピーされます(メモリ内で別領域が確保される)。
しかし、その要素内で入れ子になった 2 段階目以降の要素 (= value
プロパティ) は、原本(コピー元配列)のそれを変わらず参照しています(メモリ内の同じ領域を共有している)。これを変更すると原本の要素を変更してしまいます。
chap.07 では、もう一段上のレイヤー、つまり配列の要素そのものの追加であったためにシャローコピーによる操作で十分だったのです。
そして、前章までのようなスプレッド構文や Array.map() メソッドの使い方は、このシャローコピーにあたります。
原本(コピー元配列)の要素をミューテートから守るためには、完全にコピー(=ディープコピー)された別の配列を用意し、その配列の要素を変更しなければいけません。
ディープコピーでイミュータビリティを確保する
では、前章のコードをディープコピーで書き換えましょう。
いったん todos ステート
配列を同じく Array.map()
とスプレッド構文を組み合わせてディープコピーし、そのコピーした配列へあらためて Array.map()
を適用します。
const handleOnEdit = (id: number, value: string) => {
/**
* ディープコピー:
* 同じく Array.map() を利用するが、それぞれの要素をスプレッド構文で
* いったんコピーし、それらのコピー (= Todo 型オブジェクト) を要素とする
* 新しい配列を再生成する。
*
* 以下と同義:
* const deepCopy = todos.map((todo) => ({
* value: todo.value,
* id: todo.id,
* }));
*/
const deepCopy = todos.map((todo) => ({ ...todo }));
// ディープコピーされた配列に Array.map() を適用
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.value = value;
}
return todo;
});
// todos ステート配列をチェック(あとでコメントアウト)
console.log('=== Original todos ===');
todos.map((todo) => console.log(`id: ${todo.id}, value: ${todo.value}`));
setTodos(newTodos);
};
この章のソースコード全文:
import { useState } from 'react';
type Todo = {
value: string;
readonly id: number;
};
export const App = () => {
const [text, setText] = useState('');
const [todos, setTodos] = useState<Todo[]>([]);
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleOnSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
};
setTodos([newTodo, ...todos]);
setText('');
};
const handleOnEdit = (id: number, value: string) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.value = value;
}
return todo;
});
setTodos(newTodos);
};
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
handleOnSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleOnChange(e)} />
<input type="submit" value="追加" onSubmit={handleOnSubmit} />
</form>
<ul>
{todos.map((todo) => {
return (
<li key={todo.id}>
<input
type="text"
value={todo.value}
onChange={(e) => handleOnEdit(todo.id, e.target.value)}
/>
</li>
);
})}
</ul>
</div>
);
};
12. タスクの完了/未完了を操作できるようにする - Todo の仕様を考える (その 3)
Todo 型オブジェクトの再拡張
タスクの完了/未完了を示すフラグを Todo 型
に追加しましょう。
完了/未完了 (= true
or false
) を表すので型は Boolean 型
となります。
type Todo = {
value: string;
readonly id: number;
// 完了/未完了を示すプロパティ
checked: boolean;
};
TODO 型オブジェクト
には checked
プロパティが必須となったため、 handleOnSubmit()
メソッドを更新します。
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
// 初期値(todo 作成時)は false
checked: false,
};
setTodos([newTodo, ...todos]);
それぞれの todo
の前へ、完了/未完了を操作をするためのチェックボックスを置きます。
<ul>
{todos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.checked}
onChange={(e) => e.preventDefault()}
/>
<input
type="text"
value={todo.value}
onChange={(e) => handleOnEdit(todo.id, e.target.value)}
/>
</li>
);
})}
</ul>
チェックボックスがチェックされたときのコールバック関数を作成する
前々章の handleOnEdit()
コールバック関数とパターンは同じです。
どの todo
がチェックされたのか特定するための id
と checked
プロパティの値を引数として受け取り、その todo 型オブジェクト
の checked
プロパティを反転させます。
const handleOnCheck = (id: number, checked: boolean) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.checked = !checked;
}
return todo;
});
setTodos(newTodos);
};
チェックボックスのイベントへ紐付けましょう。
return (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.checked}
onChange={() => handleOnCheck(todo.id, todo.checked)}
/>
<input
type="text"
value={todo.value}
onChange={(e) => handleOnEdit(todo.id, e.target.value)}
/>
</li>
);
このままではチェック済みのタスクも編集できてしまうので、チェック済みの項目は入力フォームを無効にします。
<input
type="text"
disabled={todo.checked}
value={todo.value}
onChange={(e) => handleOnEdit(todo.id, e.target.value)}
/>
この章のソースコード全文 App.jsx:
import { useState } from 'react';
type Todo = {
value: string;
readonly id: number;
checked: boolean;
};
export const App = () => {
const [text, setText] = useState('');
const [todos, setTodos] = useState<Todo[]>([]);
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleOnSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
};
setTodos([newTodo, ...todos]);
setText('');
};
const handleOnEdit = (id: number, value: string) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.value = value;
}
return todo;
});
setTodos(newTodos);
};
const handleOnCheck = (id: number, checked: boolean) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.checked = !checked;
}
return todo;
});
setTodos(newTodos);
};
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
handleOnSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleOnChange(e)} />
<input type="submit" value="追加" onSubmit={handleOnSubmit} />
</form>
<ul>
{todos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.checked}
onChange={() => handleOnCheck(todo.id, todo.checked)}
/>
<input
type="text"
disabled={todo.checked}
value={todo.value}
onChange={(e) => handleOnEdit(todo.id, e.target.value)}
/>
</li>
);
})}
</ul>
</div>
);
};
13. 登録済みの todo を削除可能にする - Todo の仕様を考える (その 4)
Todo 型オブジェクトの再拡張ふたたび
タスクの削除/未削除を示すフラグを Todo 型
に追加しましょう。
これも true or false を表すプロパティなので型は Boolean 型
となります。
type Todo = {
value: string;
readonly id: number;
checked: boolean;
removed: boolean;
};
前章の checked
のときと同じく、 handleOnSubmit()
メソッドを更新する必要があります。
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
removed: false,
};
setTodos([newTodo, ...todos]);
削除ボタンの追加
それぞれの入力フォームの後ろへ削除ボタンを追加します。
return (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.checked}
onChange={() => handleOnCheck(todo.id, todo.checked)}
/>
<input
type="text"
disabled={todo.checked}
value={todo.value}
onChange={(e) => handleOnEdit(todo.id, e.target.value)}
/>
<button onClick={() => console.log('removed!')}>削除</button>
</li>
);
削除ボタンがクリックされたときのコールバック関数を作成する
これも前章の handleOnChecked()
とまったく同じパターンです。
同様に onClick
イベントへ紐付けします。
const handleOnRemove = (id: number, removed: boolean) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.removed = !removed;
}
return todo;
});
setTodos(newTodos);
};
すでに削除済みかどうかを可視化するため、todo.removed
の値によってボタンのラベルを入れ替えましょう。
<button onClick={() => handleOnRemove(todo.id, todo.removed)}>
{todo.removed ? '復元' : '削除'}
</button>
削除されたアイテムは改変できないようにするため、チェックボックスと入力フォームも無効化します。
<input
type="checkbox"
disabled={todo.removed}
checked={todo.checked}
onChange={() => handleOnCheck(todo.id, todo.checked)}
/>
<input
type="text"
disabled={todo.checked || todo.removed}
value={todo.value}
onChange={(e) => handleOnEdit(todo.id, e.target.value)}
/>
この章のソースコード全文 App.jsx:
import { useState } from 'react';
type Todo = {
value: string;
readonly id: number;
checked: boolean;
removed: boolean;
};
export const App = () => {
const [text, setText] = useState('');
const [todos, setTodos] = useState<Todo[]>([]);
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleOnSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
removed: false,
};
setTodos([newTodo, ...todos]);
setText('');
};
const handleOnEdit = (id: number, value: string) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.value = value;
}
return todo;
});
setTodos(newTodos);
};
const handleOnCheck = (id: number, checked: boolean) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.checked = !checked;
}
return todo;
});
setTodos(newTodos);
};
const handleOnRemove = (id: number, removed: boolean) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.removed = !removed;
}
return todo;
});
setTodos(newTodos);
};
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
handleOnSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleOnChange(e)} />
<input type="submit" value="追加" onSubmit={handleOnSubmit} />
</form>
<ul>
{todos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
disabled={todo.removed}
checked={todo.checked}
onChange={() => handleOnCheck(todo.id, todo.checked)}
/>
<input
type="text"
disabled={todo.checked || todo.removed}
value={todo.value}
onChange={(e) => handleOnEdit(todo.id, e.target.value)}
/>
<button onClick={() => handleOnRemove(todo.id, todo.removed)}>
{todo.removed ? '復元' : '削除'}
</button>
</li>
);
})}
</ul>
</div>
);
};
14. タスクをフィルタリングする機能を追加する
このままでは、完了済みアイテムや削除済みアイテムもいっしょにそのまま表示されてしまうので、タスクをフィルタリングする機能を追加します。
フィルタリングするセレクタを作成
ここでも onChange
イベントへはとりあえずダミーを与えておきます。
<div>
<select defaultValue="all" onChange={(e) => e.preventDefault()}>
<option value="all">すべてのタスク</option>
<option value="checked">完了したタスク</option>
<option value="unchecked">現在のタスク</option>
<option value="removed">ごみ箱</option>
</select>
<form
onSubmit={(e) => {
e.preventDefault();
handleOnSubmit();
}}
>
~ snip ~
</div>
filter
ステートを追加する
現在のフィルターを格納する フィルターの状態をあらわす Filter 型
を新設し、 その種別は4種類とします。
type Filter = 'all' | 'checked' | 'unchecked' | 'removed';
フィルター | タスクの種別 |
---|---|
all |
すべてのタスク(削除済みのタスクをのぞく) |
checked |
完了したタスク |
unchecked |
現在の(未完了の)タスク |
removed |
ごみ箱(削除済みのタスク) |
前項の <option />
タグの値を Filter 型のステート
として保持しましょう。
export const App = () => {
const [text, setText] = useState('');
const [todos, setTodos] = useState<Todo[]>([]);
// 追加
const [filter, setFilter] = useState<Filter>('all');
上のセレクタの値が変化 (onChange
イベントの発火)すると filter ステート
を更新させるようにします。
Filter
を単なる string 型 にすれば下のようなキャスト(=型変換)は不要ですが、次項の switch 文で型によるエディタの補完を享受するため、あえて Filter 型
を適用しています。
// e.target.value: string を Filter 型にキャストする
<select
defaultValue="all"
onChange={(e) => setFilter(e.target.value as Filter)}
>
<option value="all">すべてのタスク</option>
<option value="checked">完了したタスク</option>
<option value="unchecked">現在のタスク</option>
<option value="removed">ごみ箱</option>
</select>
Todo 型の配列
をリスト表示する
フィルタリング後の todos ステート
配列の表示方法を変化させる関数を作成しましょう。
-
<ul></ul>
タグの中で展開されているtodos ステート
をタグへ渡す前に加工する - 現在の
filter ステート
に応じてTodo 型配列
の要素をフィルタリングする - Array.prototype.filter() メソッドは、配列の各要素の中から条件に合致した要素を抽出して新しい配列を生成する非破壊的メソッド
- Todo 型オブジェクト内のプロパティを編集するわけではないので、イミュータビリティには影響がない
const filteredTodos = todos.filter((todo) => {
// filter ステートの値に応じて異なる内容の配列を返す
switch (filter) {
case 'all':
// 削除されていないもの全て
return !todo.removed;
case 'checked':
// 完了済 **かつ** 削除されていないもの
return todo.checked && !todo.removed;
case 'unchecked':
// 未完了 **かつ** 削除されていないもの
return !todo.checked && !todo.removed;
case 'removed':
// 削除済みのもの
return todo.removed;
default:
return todo;
}
});
todos ステート
を展開する <ul></ul>
タグにフィルタリング済みのリストを渡すように書き換えます。
<ul>
- {todos.map((todo) => {
+ {filteredTodos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
disabled={todo.removed}
「ごみ箱」
や 「完了済みのタスク」
が表示されている時は、あらたなタスクを追加できないように入力フォームは無効化しましょう。
<form
onSubmit={(e) => {
e.preventDefault();
handleOnSubmit();
}}
>
<input
type="text"
value={text}
disabled={filter === 'checked' || filter === 'removed'}
onChange={(e) => handleOnChange(e)}
/>
<input
type="submit"
value="追加"
disabled={filter === 'checked' || filter === 'removed'}
onSubmit={(e) => handleOnSubmit(e)}
/>
</form>
この章のソースコード全文 App.jsx:
import { useState } from 'react';
type Todo = {
value: string;
readonly id: number;
checked: boolean;
removed: boolean;
};
type Filter = 'all' | 'checked' | 'unchecked' | 'removed';
export const App = () => {
const [text, setText] = useState('');
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<Filter>('all');
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleOnSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
removed: false,
};
setTodos([newTodo, ...todos]);
setText('');
};
const handleOnEdit = (id: number, value: string) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.value = value;
}
return todo;
});
setTodos(newTodos);
};
const handleOnCheck = (id: number, checked: boolean) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.checked = !checked;
}
return todo;
});
setTodos(newTodos);
};
const handleOnRemove = (id: number, removed: boolean) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.removed = !removed;
}
return todo;
});
setTodos(newTodos);
};
const filteredTodos = todos.filter((todo) => {
switch (filter) {
case 'all':
return !todo.removed;
case 'checked':
return todo.checked && !todo.removed;
case 'unchecked':
return !todo.checked && !todo.removed;
case 'removed':
return todo.removed;
default:
return todo;
}
});
return (
<div>
<select
defaultValue="all"
onChange={(e) => setFilter(e.target.value as Filter)}
>
<option value="all">すべてのタスク</option>
<option value="checked">完了したタスク</option>
<option value="unchecked">現在のタスク</option>
<option value="removed">ごみ箱</option>
</select>
<form
onSubmit={(e) => {
e.preventDefault();
handleOnSubmit();
}}
>
<input
type="text"
value={text}
disabled={filter === 'checked' || filter === 'removed'}
onChange={(e) => handleOnChange(e)}
/>
<input
type="submit"
value="追加"
disabled={filter === 'checked' || filter === 'removed'}
onSubmit={handleOnSubmit}
/>
</form>
<ul>
{filteredTodos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
disabled={todo.removed}
checked={todo.checked}
onChange={() => handleOnCheck(todo.id, todo.checked)}
/>
<input
type="text"
disabled={todo.checked || todo.removed}
value={todo.value}
onChange={(e) => handleOnEdit(todo.id, e.target.value)}
/>
<button onClick={() => handleOnRemove(todo.id, todo.removed)}>
{todo.removed ? '復元' : '削除'}
</button>
</li>
);
})}
</ul>
</div>
);
};
15. ごみ箱を空にする機能を追加する
「ゴミ箱を空にする」ボタンの作成
フィルターで「ごみ箱」の Todo リストを表示しているときには、削除済みタスクを完全に消去できるよう機能追加しましょう。
フィルターが「ごみ箱」の場合は「ゴミ箱を空にする」ボタンを表示し、それ以外のときは従前の入力フォームを表示するよう改修します。
また、フィルターが「完了済みのタスク」であるときに無効化した入力フォームを表示する意味が無くなったのでこれも非表示にしましょう。
<option value="removed">ごみ箱</option>
</select>
{/* フィルターが `removed` のときは「ごみ箱を空にする」ボタンを表示 */}
{filter === 'removed' ? (
<button onClick={() => console.log('remove all')}>
ごみ箱を空にする
</button>
) : (
// フィルターが `checked` でなければ入力フォームを表示
filter !== 'checked' && (
<form
onSubmit={(e) => {
e.preventDefault();
handleOnSubmit();
}}
>
<input
type="text"
value={text}
disabled={filter === 'checked' || filter === 'removed'}
onChange={(e) => handleOnChange(e)}
/>
<input
type="submit"
value="追加"
disabled={filter === 'checked' || filter === 'removed'}
onSubmit={handleOnSubmit}
/>
</form>
)
)}
<ul>
{filteredTodos.map((todo) => {
こうなると入力フォームが描画される場合には filter === 'removed'
や filter === 'checked'
という状態が発生し得ないので、入力フォームからこれらを削除しなければいけません。
<form
onSubmit={(e) => {
e.preventDefault();
handleOnSubmit();
}}
>
<input
type="text"
value={text}
onChange={(e) => handleOnChange(e)}
/>
<input type="submit" value="追加" onSubmit={handleOnSubmit} />
</form>
「ゴミ箱を空にする」コールバック関数の作成と紐付け
todos ステート
配列から removed
フラグが立っている要素を取り除くのみなので、これまでと同様のパターンで処理すれば良いでしょう。
const handleOnEmpty = () => {
// シャローコピーで事足りる
const newTodos = todos.filter((todo) => !todo.removed);
setTodos(newTodos);
};
{filter === 'removed' ? (
// コールバックに handleOnEmpty() を渡す
<button onClick={handleOnEmpty}>ゴミ箱を空にする</button>
) : (
また、ゴミ箱が空の場合(= removed
フラグが立っているタスクが todos ステート
配列に存在しない)にはボタンを無効化します。
<button
onClick={handleOnEmpty}
disabled={todos.filter((todo) => todo.removed).length === 0}
>
ゴミ箱を空にする
</button>
ここまでで React による Todo アプリはいったん完成です。
この章のソースコード全文 App.jsx:
import { useState } from 'react';
type Todo = {
value: string;
readonly id: number;
checked: boolean;
removed: boolean;
};
type Filter = 'all' | 'checked' | 'unchecked' | 'removed';
export const App = () => {
const [text, setText] = useState('');
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<Filter>('all');
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleOnSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
removed: false,
};
setTodos([newTodo, ...todos]);
setText('');
};
const handleOnEdit = (id: number, value: string) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.value = value;
}
return todo;
});
setTodos(newTodos);
};
const handleOnCheck = (id: number, checked: boolean) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.checked = !checked;
}
return todo;
});
setTodos(newTodos);
};
const handleOnRemove = (id: number, removed: boolean) => {
const deepCopy = todos.map((todo) => ({ ...todo }));
const newTodos = deepCopy.map((todo) => {
if (todo.id === id) {
todo.removed = !removed;
}
return todo;
});
setTodos(newTodos);
};
const handleOnEmpty = () => {
const newTodos = todos.filter((todo) => !todo.removed);
setTodos(newTodos);
};
const filteredTodos = todos.filter((todo) => {
switch (filter) {
case 'all':
return !todo.removed;
case 'checked':
return todo.checked && !todo.removed;
case 'unchecked':
return !todo.checked && !todo.removed;
case 'removed':
return todo.removed;
default:
return todo;
}
});
return (
<div>
<select
defaultValue="all"
onChange={(e) => setFilter(e.target.value as Filter)}
>
<option value="all">すべてのタスク</option>
<option value="checked">完了したタスク</option>
<option value="unchecked">現在のタスク</option>
<option value="removed">ごみ箱</option>
</select>
{filter === 'removed' ? (
<button
onClick={handleOnEmpty}
disabled={todos.filter((todo) => todo.removed).length === 0}
>
ごみ箱を空にする
</button>
) : (
filter !== 'checked' && (
<form
onSubmit={(e) => {
e.preventDefault();
handleOnSubmit();
}}
>
<input
type="text"
value={text}
onChange={(e) => handleOnChange(e)}
/>
<input type="submit" value="追加" onSubmit={handleOnSubmit} />
</form>
)
)}
<ul>
{filteredTodos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
disabled={todo.removed}
checked={todo.checked}
onChange={() => handleOnCheck(todo.id, todo.checked)}
/>
<input
type="text"
disabled={todo.checked || todo.removed}
value={todo.value}
onChange={(e) => handleOnEdit(todo.id, e.target.value)}
/>
<button onClick={() => handleOnRemove(todo.id, todo.removed)}>
{todo.removed ? '復元' : '削除'}
</button>
</li>
);
})}
</ul>
</div>
);
};
Discussion