React と TypeScript で簡単 TODO アプリ
1. はじめに
この記事では、React 16.8 で導入された Hooks の機能を使って、ハンズオン形式で To Do リストを管理するためのアプリケーション(以下、Todo アプリ)を作成していきます。
また、現在のフロントエンド開発環境でデファクト・スタンダードとなりつつある TypeScript を利用することによって型安全な実装の実現も目指します。
2. 開発環境の準備
Vite.js で React プロジェクトを作成する
フロントエンドツール Vite.js(以下、Vite)を利用して React + TypeScript のプロジェクトを作成します。
ターミナルで以下のコマンドを実行します。
npm create vite
- そのまま Enter キーを押します。
プロジェクトに名前をつける
-
React
を選択します。
フレームワークの選択
-
TypeScript
を選択します。
JavsScript または TypeScript の選択
% npm create vite
✔ Project name: … vite-project
✔ Select a framework: › react
✔ Select a variant: › react-ts
Scaffolding project in /Users/foo/vite-project...
Done. Now run:
cd vite-project
npm install
npm run dev
- Vite の指示に従い、さっそくこのプロジェクトを起動してみましょう。
% cd vite-project
% npm install
% npm run dev
Vite.js ローカルサーバーが起動
この画面で o
+ Enter
をタイプするか、ブラウザで http://localhost:5173 にアクセスすると React アプリが表示されます。
h
+ Enter
をタイプしてヘルプを表示
React デベロッパーツールの準備
Google Chrome、Mozilla Firefox、または Microsoft Edge には React Developer Tools という拡張機能がそれぞれ用意されています。
これは、React コンポーネントの状態をブラウザの開発者ツール画面に表示してくれる拡張機能です。必ずインストールしておきましょう。
開発者ツールを表示するには、キーボードから Ctrl + Shift + I
を打鍵します(※)。Components
タブからコンポーネント名(ここでは App
)を選択すると React コンポーネントの状態を確認できます。
ホットリロード機能の確認
VS Code でプロジェクトフォルダを開き、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' に変わっている
これで準備が整いました。次章からさっそく React アプリを作成していきましょう。
3. React コンポーネント(関数コンポーネント)の作成
todo(タスク)を入力するためのフォームを持った、関数コンポーネントを作成するところから始めましょう。
関数コンポーネントは、React コンポーネントの一種であり、JavaScript 関数を使って UI コンポーネントを定義する方法を提供します。UI は JSX (JavaScript XML) を使用して記述され、その JSX を返すことで、React は UI をレンダリングします。
// Hello コンポーネント
function Hello() {
return <h1>Hello.</h1>;
}
Hello コンポーネント
最初のコード
src
ディレクトリ内にある main.tsx
と App.tsx
そして index.css
の 3 つのファイルを、以下のコードで置き換えてください。
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./index.css";
const root = createRoot(document.getElementById("root") as Element);
root.render(
<StrictMode>
<App />
</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>
);
};
#root {
margin: 0;
padding: 0;
}
select {
margin: 0.5em;
padding: 0.5em;
}
input[type='text'] {
width: 50vw;
padding: 0.5em;
margin: 0 0.5em;
}
input[type='submit'] {
padding: 0.1em 0.5em;
}
ul {
list-style: none;
margin-left: 0.5em;
padding-left: 0;
}
li {
margin-bottom: 0.5em;
}
App コンポーネント
main.tsx
createRoot
メソッドと render
メソッド
上段の main.tsx
では、createRoot メソッドを使って、index.html
内の <div id="root">
という要素を取得し、それを React ルートとしています。
<body>
<div id="root"></div>
<!-- 省略 -->
</body>
const root = createRoot(document.getElementById('root') as Element);
そして、その React ルートが持つ render メソッドは、DOM 内部へ React 要素である <App />
コンポーネントをレンダリングします。
root.render(
<StrictMode>
<App />
</StrictMode>
);
as Element
createRoot
の引数内では、取得した HTML 要素を as Element
として型アサーションしています。型アサーションとは、型推論を上書きして別の型を割り当てる機能です。
// Element 型としてアサーション
createRoot(document.getElementById("root") as Element);
もしも、この as Element
が無いとどうなるでしょうか?
型 'HTMLElement | null' の引数を型 'Element | DocumentFragment' のパラメーターに割り当てることはできません。
React アプリをマウントする HTML ファイルに root
という id を持った要素が存在しない (= null) という事態は十分にあり得ます。
そのため、TypeScript の世界では document.getElementById
から得られる値は、常に HTMLElement 型または Null 型 となります。
createRoot
は、その引数に Element 型もしくは DocumentFragment 型の値しか受け取れないので、null となる可能性を持つ値を渡すことは出来ません。そのため、ここでは Element 型へと型アサーションしているのです。
別解として、null ではないことを保証する Non-null アサーション演算子: !
を利用する方法もあります。
// "!" 演算子で null でないことを保証する
createRoot(document.getElementById('root')!);
App.tsx
App コンポーネント内では、JSX で作成した Todo 入力フォームを return しています。
export const App = () => {
return (
<div>
<form onSubmit={(e) => e.preventDefault()}>
{/* 省略 */}
</form>
</div>
);
};
JSX (JavaScript XML) は、JavaScript 内で HTML に似た文法で UI を直感的に組み立てることを可能とします。
この時点での JSX に関する注意点は以下の 2 つです。
- 必ず単一の JSX 要素(上の例では
<div>~</div>
)を return する必要があります。
以下のような例ではエラーとなってしまいます。
// ❌ ダメな例
const Example = () => {
// 単一の JSX 要素でないためエラー
return (
<p>最初の行</p>
<p>次の行</p>
)
}
JSX 要素を単一にラップするためだけに <div>
要素などを使いたくない場合は、代わりに JSX フラグメント <> ~ </>
を使って JSX 文を囲ってください。
// 🟢 OK!
const Example = () => {
return (
<>
<p>最初の行</p>
<p>次の行</p>
</>
)
}
- JSX 内での
onsubmit
イベントは、onSubmit
(ロワー・キャメルケース)として記述する必要があります。
これは、その他のonClick
やonChange
といったイベントリスナーでも同様です。
<input type="text" value="" onChange={(e) => e.preventDefault()} />
これから
以降、この関数コンポーネント App
をベースとして Todo アプリを作成していきます。
いまのところ onSubmit
や onChange
などのイベントリスナーでは、イベントが発生しても preventDefault() してしまっているので特に何も起きません。
4. フォームに入力された文字列を状態 (=state) として保持する
useState フックの導入
フォームに入力された文字列を 状態 (=ステート) として保持するため、useState
フックを導入します。
React がコンポーネントのステートに応じて DOM をリアクティブに更新する様子をチェックしましょう。
useState フックの構文
React では、コンポーネントに状態を「記憶」させるための useState
という特別な関数が用意されています。
const [text, setText] = React.useState('hello');
-
useState
:- 引数となるのはステートの初期値 (=
'hello'
) です - 現在のステート
text
と、それを更新するための関数setText
とをペアにした配列を返します
- 引数となるのはステートの初期値 (=
-
text
: 現在のステートの値が格納された変数です -
setText
: ステートを更新するメソッドです-
'set' + ステート
(ロワー・キャメルケース)とするのが通例です -
setText('bye')
のようにしてステートを更新します
-
text ステートを作成
App コンポーネントへ text
ステートの値を「記憶」させ、その値に応じてリアクティブに DOM を書き換えましょう。
// React から useState フックをインポート
import { useState } from 'react';
export const App = () => {
// 初期値: 空文字列 ''
const [text, setText] = useState('');
return (
<div>
<form onSubmit={(e) => e.preventDefault()}>
<input
type="text"
// text ステートが持っている入力中テキストの値を value として表示
value={text}
// onChange イベント(=入力テキストの変化)を text ステートに反映する
onChange={(e) => setText(e.target.value)}
/>
<input type="submit" /> {/* ← 省略 */}
</form>
{/* ↓ DOM のリアクティブな反応を見るためのサンプル */}
<p>{text}</p>
{/* ↑ あとで削除 */}
</div>
);
};
DOM がリアクティブに変化
開発者ツールの Components タブに State が表示されている
ステートの値が更新されると、React はそのステートを持つコンポーネントとその子コンポーネントを再レンダリングします。
言い換えると、setState
メソッドの実行は、コンポーネントの再レンダリングをトリガーします。
再レンダリングされる部分のハイライト
ステート更新の原則
React では、原則として setState
メソッドを利用しないでステートの値を書き換えてはいけません。
const [jotai, setJotai] = useState('いまの状態');
// ❌ やってはいけない
jotai = 'あたらしい状態';
// 🟢 OK!
setJotai('あたらしい状態');
その理由は、上で述べた通りコンポーネントの再レンダリングが正しくトリガーされないからですが、他にもステートのイミュータビリティが保てなくなるからという事情もあります。
ステートのイミュータビリティについては、のちの章で解説します。
5. Todo(タスク)の仕様を考える (その 1)
タスクの構成要素
ひとつのタスク (todo) を JavaScript オブジェクトと考えると、そのオブジェクトにはタスクの内容を保持するプロパティ(変数)が必要となります。ここでは、これを value
プロパティとします。
// タスクを表すオブジェクト
const todo01 = {
value: "美容院へ行く",
};
Todo 入力フォームに入力されたテキスト文字列が代入されるので、この value
プロパティは string型
となります。
これから作成される複数の todo のひな型として Todo型
オブジェクト の型エイリアスを定義しましょう。型エイリアスは、 以下のように type キーワードを使って定義します。
// "Todo型" の定義
type Todo = {
// プロパティ value は文字列型
value: string;
};
export const App = () => { /* ... */}
型エイリアスは、既存の型を再利用して新しい型をカスタム定義する方法です。型エイリアスを使用すると、複雑な型を定義することができ、コードの可読性を向上させることができます。
なお、型エイリアスの名前は大文字(アッパー・キャメルケース)で始まるようにすることを強く推奨します。これは 「値ではなく、型である」 ことを明示するためです。
また、: string
のような部分を型注釈といい、その変数にどんな値が代入可能かを指定したり、関数の場合には引数と戻り値の型を指定したりできます。
const price: number = 1000; // <-- 数値型
const isCheap: boolean = true; // <-- 論理型
// 引数に文字列型と数値型を取り、文字列型を返す
function treat(name: string, times: number): string => {
return `${name} さんに食事を ${times} 回ごちそうしました`
}
タスクリストを保持するステートの作成
複数のタスクをリストとして保持しておくステートも作成しましょう。
todos
ステート
このタスクリスト、つまり複数の todo は、Todo型
オブジェクトの配列: Todo[]
となります。
export const App = () => {
const [text, setText] = useState('');
// 追加
const [todos, setTodos] = useState<Todo[]>([]);
return (<div>{/* ... */}</div>)
useState<>
の中に型を指定しておくと、これと型が異なる値をステートに代入できなくなるため、ステートの型安全性が常に保証されます。
上記のコードで言うと、todos
ステートの初期値は空配列 []
となっていますが、このステートには Todo 型オブジェクトの配列以外の値を代入することができません。
例えば、以下のような setState
メソッドの実行はエラーとなります。
// ❌ Todo型の配列じゃない
setTodos([0, 1, 2]);
// 🟢 これは OK!
setTodos([{ valude: "次のタスク" }, { value: "最初のタスク" }]);
6. 配列ステートの操作には要注意
配列ステートを操作する
前章で作成した Todos ステート
は Todo型
オブジェクトの配列ですが、配列のステートを操作しなければならない場合、そのステート配列を直接触ってはいけません。
例えば、以下のようなステート操作は行ってはいけません。
const [todos, setTodos] = useState([{ value: "現在のタスク" }]);
// ❌ やってはいけない
// ステート配列の末尾に新しい要素を直接に追加
todos.push({ value: "新しいタスク" });
その理由は、「ステートのイミュータビリティ(immutability, 不変性)が保持できなくなる」からですが、これを詳しく見ていきましょう。
イミュータブルな操作とは?
イミュータブルな操作とは、その操作の対象となった元の値を不変(=イミュータブル)に保つ操作のことです。
const array1 = [0, 1, 2];
const array2 = [0, 1, 2];
// Array.push()
console.log(array1.push(3)); // --> 4
// スプレッド構文(次章でも解説します)
console.log([...array2, 3]); // --> ▶️(4) [0, 1, 2, 3]
上の例での下 2 行では、どちらもそれぞれの元の配列に 3
という要素を追加しています。
3
を追加して出力
では、元の配列の値はどうなったでしょうか?
console.log(array1);
▶️(4) [0, 1, 2, 3]
console.log(array2);
▶️(3) [0, 1, 2]
元の配列の値が変わってしまっている
Array.push()
メソッドが元の配列を 変更(=ミューテート) してしまったのに対し、スプレッド構文を使った要素の追加では元の配列の イミュータビリティ(=不変性) が保たれています。
ここでの Array.push()
メソッドが「ミュータブルな操作」、スプレッド構文が「イミュータブルな操作」です。
なぜ React ではイミュータブルな操作が必要なのか?
では、なぜ React ではイミュータブルな操作が必要とされるのでしょうか?
パフォーマンス上の理由や再レンダリングについての予測可能性のため、React はコンポーネントの変化をオブジェクトの同一性(差分)チェックで検知しています。
ミュータブルな操作をしてしまうとコピー元の情報も変更されてしまうため、変更前と変更後の差分を React が検知できなくなってしまいます。
一方、イミュータブルな操作では変更前と変更後の情報をそれぞれ参照しているので、React は差分を検知できます。
また、元のステートが変更されていないので、それを履歴として残すことでその段階まで巻き戻すような機能の実装も可能となります。
ふたたび上でも挙げた例で言うと、todos
ステートへの以下のような操作はいけません。
// ❌ やってはいけない
// 元配列を変更するかたちでステートを更新
const newTodos = todos.unshift({ value: "新しいタスク" });
setTodos(newTodos);
なぜなら、Array.prototype.push()
や Array.prototype.unshift()
は破壊的メソッドなので todos
ステートを直接ミューテート(mutate, 書き換え)してしまうからです。
配列のステートを操作する場合には、 いったんそのコピーに対して変更を加え、その変更後のコピーでステートを更新します。
const todos = [{ value: '最初のタスク' }];
// todos 配列をコピー
const newTodos = todos.slice();
/**
* またはスプレッド構文(後述)で
* const newTodos = [...todos]
*/
// コピーした配列へ Todo型オブジェクトの要素を追加
newTodos.unshift({ value: '新しいタスク' });
// それぞれの配列の内容を確認
console.log('=== old todos ===');
console.log(JSON.stringify(todos));
console.log('=== new todos ===');
console.log(JSON.stringify(newTodos));
/**
* 結果:
* === old todos ===
* [{"value":"最初のタスク"}]
*
* === new todos ===
* [{"value":"新しいタスク"},{"value":"最初のタスク"}]
*
* 元の配列 (=todos) に影響を与えることなく、
* コピーした配列 (=newTodos) へ要素が追加されている
**/
こうすることで、更新前のステートである元の配列と、新しいステートの差分を React が検知できるようになります。
コピーに変更を加えても元配列には影響がない
参考記事
- イミュータビリティは何故重要なのか(React 公式チュートリアル)
7. イベントを処理する関数を作成する
コールバック関数の作成
それでは、todos
ステートを更新、つまり新しいタスクの追加をしていきましょう。
イベントを処理する関数(=イベントハンドラー)を作成し、それを実行することでステートを更新します。
const [todos, setTodos] = useState<Todo[]>([]);
// todos ステートを更新する関数
const handleSubmit = () => {
// 何も入力されていなかったらリターン
if (!text) return;
// 新しい Todo を作成
// 明示的に型注釈を付けてオブジェクトの型を限定する
const newTodo: Todo = {
// text ステートの値を value プロパティへ
value: text,
};
/**
* 更新前の todos ステートを元に
* スプレッド構文で展開した要素へ
* newTodo を加えた新しい配列でステートを更新
**/
setTodos((todos) => [newTodo, ...todos]);
// フォームへの入力をクリアする
setText('');
};
スプレッド構文 ...todos
は、元の todos
配列の「すべての要素を列挙する」ということを意味します。つまり、[]
の先頭へ newTodo
を追加した新たな配列を作成していることになります。
また、ここでは setState
メソッドの引数へ関数を与えています。
じつは、setState
メソッドの引数には、値だけでなく関数を渡すこともできます。そして、その関数は更新前のステートを引数にとり、新しいステートを返す関数となります。
// 更新前のステートの値を元に新ステートを生成
setState((prev) => prev + 1);
このように更新後のステートが更新前のステートの値に依存している場合には、setState
メソッドには値ではなく関数を渡すべきです。
const [state, setState] = useState(0);
// ❌ No! ステートから作った値を直接渡す
setState(state + 1);
// 🟢 OK! 「更新前」 のステートの値を元に新ステートを生成
setState((state) => state + 1);
コールバック関数をイベントに割り当てる
上の関数を onSubmit
イベント(イベントリスナー)へ紐付けましょう。
このようなイベントリスナーに渡す関数のことをコールバック関数といいます。
注意点:
- イベントリスナーに渡す関数は、 アロー関数の
() => hoge()
もしくは 引数なしのhoge
の関数そのものです -
hoge()
と記述すると即時実行されてしまうため用をなしません
なお、<form>
タグの中でいったん e.preventDefault()
しているのは Enter キー打鍵でページそのものがリロードされてしまうのを防ぐためです。
return (
<div>
{/* コールバックとして () => handleSubmit() を渡す */}
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
{/* 上に同じ */}
<input type="submit" value="追加" onSubmit={handleSubmit} />
</form>
</div>
onSubmit
イベントが発火すると handleSubmit
関数が実行され、新しいタスクが追加されます。
フォームへ入力して submit(Enter キー打鍵)すれば、開発者ツールで todos ステート
が更新されていることを確認できます。
text ステートと todos ステートの様子
text
ステート向けの関数も用意する
上の例で要領を得ましたので、text ステート
についても JSX の中で直接 setText
していた部分を関数 handleChange()
として書き出しましょう。
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
<input type="text" value={text} onChange={(e) => handleChange(e)} />
イベントの型を調べる
イベントの型がわからない時は、VS Code であればイベント上でマウスカーソルをホバーさせるとポップアップが表示されます。
この章のソースコード全文
App.tsx
import { useState } from 'react';
type Todo = {
value: string;
};
export const App = () => {
const [text, setText] = useState('');
const [todos, setTodos] = useState<Todo[]>([]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
};
setTodos((todos) => [newTodo, ...todos]);
setText('');
};
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleChange(e)} />
<input type="submit" value="追加" onSubmit={handleSubmit} />
</form>
</div>
);
};
8. todos ステートを展開してページに表示する
todos
ステート配列を Array.map()
メソッドで展開する
todos
ステートを展開し、タスク一覧としてページに表示します。
具体的には、todos (=配列)
を非破壊メソッドである Array.prototype.map() を使って <li> ~ </li>
タグへ展開します。
Array.map()
メソッドは、与えられた関数を配列のすべての要素に対して呼び出し、その結果からなる新しい配列を生成します。
// 例:
const items = [0, 1, 2];
const newItems = items.map((item) => item * 2); // --> [0, 2, 4]
/** for 文で同義を書いた場合 */
const newItems = [];
for (let i = 0; i < items.length; i++) {
newItems.push(items[i] * 2);
}
<div>
<form onSubmit={/* 省略 */}>
{/* 省略 */}
</form>
<ul>
{todos.map((todo) => {
return <li>{todo.value}</li>;
})}
</ul>
</div>
ただし、これだけでは各 <li>
に key プロパティが設定されていないため、以下のような警告が表示されてしまいます。
チュートリアル:React の導入 - key を選ぶ(公式)
警告:リストのそれぞれの要素は一意な key プロパティを持っているべきです
リストをレンダーするときの key の重要性
なぜリストの各項目に key プロパティが必要となるのでしょうか?
React はリストをレンダーする際、どのアイテムが変更になったのか特定できる必要があります。リストのアイテムは追加された可能性も、削除された可能性も、並び替えられた可能性も、中身自体が変更になった可能性もあるからです。
変更・追加・削除・並び替えを検知するためには、リストの各項目を特定する一意な識別子が必要です。
この一意な識別子こそが key プロパティであり、上の警告は『各項目を特定できないため、リストに変更が加えられても正しく再レンダーできない可能性があります』という意味で表示されているのです。
次章では、この key プロパティを各項目へ与えるため、Todo型
オブジェクトの仕様について再考します。
9. Todo(タスク) の仕様を考える (その 2)
Todo型
オブジェクトへプロパティを追加する
前章で見た通り、todos
ステート配列をリストとして展開するためには、配列の各要素へその識別子を持たせる必要があります。
配列の各要素、つまり Todo型
のタスクそれぞれに一意な key を持たせる必要が生じたため、Todo型
そのものを拡張しなければなりません。
ここでは、id
プロパティとして一意な数字 (number型
) を持たせることにします。
また、一意であるはずの識別子が書き換えられてはならないため、readonly(読み取り専用) のプロパティとします。
type Todo = {
value: string;
+ readonly id: number;
};
Todo型
オブジェクトには id
プロパティの指定が必須となったため、handleSubmit()
メソッドを更新しなければいけません。
const handleSubmit = () => {
e.preventDefault();
if (!text) return;
const newTodo: Todo = {
value: text,
/**
* Todo型オブジェクトの型定義が更新されたため、
* number型の id プロパティの存在が必須になった
*/
id: new Date().getTime(),
};
setTodos((todos) => [newTodo, ...todos]);
setText('');
};
これでそれぞれのタスクが一意なプロパティを持つようになったので、これを key プロパティへ適用しましょう。
<li> ~ </li>
タグに key (=todo.id)
を付加します。
<ul>
{todos.map((todo) => {
return <li key={todo.id}>{todo.value}</li>;
})}
</ul>
各 Todo に id
プロパティが追加された
なお、以下のように key へ配列のインデックスを利用することは推奨されていません。
// ❌ No Good
list.map((item, index) => <li key={index}>{item}</li>)
その理由は前章で述べた key
の重要性とほぼ同じですが、ここでは公式ドキュメントからの引用を掲載しておきます。
アイテムのインデックスを key として使用したくなるかもしれません。実際、key を指定しなかった場合、React はデフォルトでインデックスを使用します。しかし、アイテムが挿入されたり削除されたり、配列を並び替えたりすると、レンダーするアイテムの順序も変わります。インデックスをキーとして利用すると、微妙かつややこしいバグの原因となります。
配列のインデックスを key に利用するのは、何らかの理由で各要素へ一意な識別子を与えることが困難な場合のあくまでも最終手段として捉えておくべきでしょう。
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 handleEdit = (id: number, value: string) => {
setTodos((todos) => {
/**
* 引数として渡された todo の id が一致する
* 更新前の todos ステート内の todo の
* value プロパティを引数 value (= e.target.value) に書き換える
*/
const newTodos = todos.map((todo) => {
if (todo.id === id) {
todo.value = value;
}
return todo;
});
// todos ステートを更新
return newTodos;
});
};
前述の通り、Array.prototype.map() は、与えられた関数を配列のすべての要素に対して呼び出し、その結果からなる新しい配列を生成する非破壊的メソッドです。
上のコールバック関数を <input onChange={} />
イベントに紐付けます。
<ul>
{todos.map((todo) => {
return (
<li key={todo.id}>
<input
type="text"
value={todo.value}
onChange={(e) => handleEdit(todo.id, e.target.value)}
/>
</li>
);
})}
</ul>
ステートのイミュータビリティは保たれているか?
では、上の処理を行うことによってもステートのイミュータビリティ(immutability, 不変性)は保たれているのでしょうか?
結論から言うと、この手法ではイミュータビリティを保つことはできません。
上のコードの newTodos
配列を作成した後かつ todos ステート
を更新する前の todos ステート
の値を確認してみましょう。
const handleEdit = (id: number, value: string) => {
setTodos((todos) => {
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}`);
});
// ここまで
return newTodos;
});
};
結果は以下のようになります。
return newTodos
が実行される前に todos ステート
配列が直接ミューテートされてしまっています。
Array.prototype.map() は、「新しい配列を生成する非破壊的メソッド」であるはずなのに何故なのでしょうか?
11. 配列ステートの操作には要注意 (その 2)
シャロー(浅い)コピー
第 7 章ではスプレッド構文によって保たれたイミュータビリティが、前章の Array.prototype.map() メソッドでは保てませんでした。
それは、これまでの Array.map()
やスプレッド構文によるコピーがシャローコピー(薄いコピー)と呼ばれるものだからです。
シャローコピーでは、1 段階目の要素のみ(ここでは Todos
ステート配列の各オブジェクト)がコピーされます(メモリ内で別領域が確保される)。
しかし、そのオブジェクト内で入れ子になった 2 段階目以降のプロパティ (= value
, id
) は、コピー元配列のそれを変わらず参照しています(メモリ内の同じ領域を参照している)。これを変更するとコピー元配列のプロパティも変更してしまいます。
第 7 章では、もう一段上の階層、つまり 1 段階目であるオブジェクトそのものの追加であったためにシャローコピーによる操作で十分だったのです。
入れ子になったプロパティを書き替える
元の値をイミュータブルに保ちつつ、オブジェクト内で入れ子になったプロパティを書き換えるには、そのオブジェクトの階層までたどって複製しなければなりません。
具体的には、配列の要素(Todo 型オブジェクト)の階層においてスプレッド構文でコピー・展開したうえで、入れ子のプロパティの値を更新します。
const handleEdit = (id: number, value: string) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
/**
* この階層でオブジェクト todo をコピー・展開し、
* その中で value プロパティを引数で上書きする
*/
return { ...todo, value: value };
}
return todo;
});
// todos ステート配列をチェック(あとでコメントアウト)
console.log('=== Original todos ===');
todos.map((todo) => {
console.log(`id: ${todo.id}, value: ${todo.value}`);
});
// ここまで
return newTodos;
});
};
return { ...todo, value: value };
の部分では、元の todo 要素をスプレッド構文でコピー・展開し、書き換え対象である value
を引数で上書きしたものを新しい要素として複製しています。
/**
* 参考:同じことをスプレッド構文なしで書いた場合
*/
const newTodos = todos.map((todo) => {
if (todo.id === id) {
// todo オブジェクトをコピー
const copyObj = Object.assign({}, todo);
// コピーの value プロパティを引数で更新
copyObj.value = value;
// コピーを返す
return copyObj;
}
return todo;
});
この章のソースコード全文
App.tsx
import { useState } from 'react';
type Todo = {
value: string;
readonly id: number;
};
export const App = () => {
const [text, setText] = useState('');
const [todos, setTodos] = useState<Todo[]>([]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
};
setTodos((todos) => [newTodo, ...todos]);
setText('');
};
const handleEdit = (id: number, value: string) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, value };
}
return todo;
});
return newTodos;
});
};
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleChange(e)} />
<input type="submit" value="追加" onSubmit={handleSubmit} />
</form>
<ul>
{todos.map((todo) => {
return (
<li key={todo.id}>
<input
type="text"
value={todo.value}
onChange={(e) => handleEdit(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
プロパティが必須となったため、 handleSubmit()
メソッドを更新します。
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
// 初期値(todo 作成時)は false
checked: false,
};
setTodos((todos) => [newTodo, ...todos]);
それぞれの項目の前へ、完了/未完了を操作をするためのチェックボックスを置きます。
<ul>
{todos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.checked}
onChange={() => console.log('checked!')}
/>
<input
type="text"
value={todo.value}
onChange={(e) => handleEdit(todo.id, e.target.value)}
/>
</li>
);
})}
</ul>
チェックボックスがチェックされたときのコールバック関数を作成する
前章の handleEdit()
関数とパターンは同じです。
どの todo
がチェックされたのか特定するための id
と checked
プロパティの値を引数として受け取り、その todo型
オブジェクトの checked
プロパティを更新します。
const handleCheck = (id: number, checked: boolean) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, checked };
}
return todo;
});
return newTodos;
});
};
同様にチェックボックスのイベントへ紐付けますが、呼び出し側で checked
プロパティの値を反転させる必要があることに注意してください。
呼び出し側で反転しておくことで、のちに「第 16 章 TypeScript のジェネリクスを使ってよく似た関数を一つにまとめる」でのイベント処理関数のリファクタリングが楽になることに気づくでしょう。
return (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.checked}
// 呼び出し側で checked フラグを反転させる
onChange={() => handleCheck(todo.id, !todo.checked)}
/>
<input
type="text"
value={todo.value}
onChange={(e) => handleEdit(todo.id, e.target.value)}
/>
</li>
);
このままではチェック済みのタスクも編集できてしまうので、チェック済みの項目は編集用フォームを無効にします。
<input
type="text"
+ disabled={todo.checked}
value={todo.value}
onChange={(e) => handleEdit(todo.id, e.target.value)}
/>
この章のソースコード全文
App.tsx
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
};
setTodos((todos) => [newTodo, ...todos]);
setText('');
};
const handleEdit = (id: number, value: string) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, value };
}
return todo;
});
return newTodos;
});
};
const handleCheck = (id: number, checked: boolean) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, checked };
}
return todo;
});
return newTodos;
});
};
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleChange(e)} />
<input type="submit" value="追加" onSubmit={handleSubmit} />
</form>
<ul>
{todos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.checked}
onChange={() => handleCheck(todo.id, !todo.checked)}
/>
<input
type="text"
disabled={todo.checked}
value={todo.value}
onChange={(e) => handleEdit(todo.id, e.target.value)}
/>
</li>
);
})}
</ul>
</div>
);
};
13. 登録済みの todo を削除可能にする - Todo の仕様を考える (その 4)
Todo型
オブジェクトの再拡張(その 2)
タスクの削除/未削除を示すフラグを Todo型
に追加しましょう。
これも true or false を表すプロパティなので型は Boolean型
となります。
type Todo = {
value: string;
readonly id: number;
checked: boolean;
removed: boolean;
};
前章の checked
のときと同じく、 handleSubmit()
メソッドを更新する必要があります。
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
removed: false, // <-- 追加
};
setTodos((todos) => [newTodo, ...todos]);
削除ボタンの追加
それぞれの項目の後ろへ削除ボタンを追加します。
return (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.checked}
onChange={() => handleCheck(todo.id, todo.checked)}
/>
<input
type="text"
disabled={todo.checked}
value={todo.value}
onChange={(e) => handleEdit(todo.id, e.target.value)}
/>
<button onClick={() => console.log('removed!')}>削除</button>
</li>
);
削除ボタンがクリックされたときのコールバック関数を作成する
これも前章の handleChecked()
とまったく同じパターンです。
同様に onClick
イベントへ紐付けします。
const handleRemove = (id: number, removed: boolean) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, removed };
}
return todo;
});
return newTodos;
});
};
すでに削除済みかどうかを可視化するため、todo.removed
の値によってボタンのラベルを入れ替えましょう。
また、前章同様に removed
フラグの反転は呼び出し側で行うことに注意しましょう。
<button onClick={() => handleRemove(todo.id, !todo.removed)}>
{todo.removed ? '復元' : '削除'}
</button>
削除されたアイテムは改変できないようにするため、チェックボックスと編集用フォームも無効化します。
<input
type="checkbox"
+ disabled={todo.removed}
checked={todo.checked}
onChange={() => handleCheck(todo.id, todo.checked)}
/>
<input
type="text"
+ disabled={todo.checked || todo.removed}
value={todo.value}
onChange={(e) => handleEdit(todo.id, e.target.value)}
/>
この章のソースコード全文
App.tsx
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
removed: false,
};
setTodos((todos) => [newTodo, ...todos]);
setText('');
};
const handleEdit = (id: number, value: string) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, value };
}
return todo;
});
return newTodos;
});
};
const handleCheck = (id: number, checked: boolean) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, checked };
}
return todo;
});
return newTodos;
});
};
const handleRemove = (id: number, removed: boolean) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, removed };
}
return todo;
});
return newTodos;
});
};
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleChange(e)} />
<input type="submit" value="追加" onSubmit={handleSubmit} />
</form>
<ul>
{todos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
disabled={todo.removed}
checked={todo.checked}
onChange={() => handleCheck(todo.id, !todo.checked)}
/>
<input
type="text"
disabled={todo.checked || todo.removed}
value={todo.value}
onChange={(e) => handleEdit(todo.id, e.target.value)}
/>
<button onClick={() => handleRemove(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();
handleSubmit();
}}
>
{/* 省略 */}
</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型
の変数を引数に取り、ステートを更新するだけの関数です。
const handleFilter = (filter: Filter) => {
setFilter(filter);
};
上の関数を onChange
イベントへ紐づけます。
// e.target.value: string を Filter型にアサーションする
<select
defaultValue="all"
onChange={(e) => handleFilter(e.target.value as Filter)}
>
<option value="all">すべてのタスク</option>
<option value="checked">完了したタスク</option>
<option value="unchecked">現在のタスク</option>
<option value="removed">ごみ箱</option>
</select>
Filter
を単なる string型
にすれば、上記コードのような型アサーションは不要ですが、次項の switch 文で型によるエディタの補完を享受するため、あえて Filter型
を適用しています。
Todo型
の配列をリスト表示する
フィルタリング後の フィルタリング後のリストを格納する変数を作成しましょう。
-
<ul> ~ </ul>
タグの中で展開されているtodos ステート
をタグへ渡す前に加工する - 現在の
filter ステート
に応じてTodo型
配列の要素をフィルタリングする - Array.prototype.filter() メソッドは、配列の各要素の中から条件に合致した要素を抽出して新しい配列を生成する非破壊的メソッド
const items = [0, 1, 2, 3, 4, 5, 6];
items.filter((item) => item % 2 === 0); // --> [0, 2, 4, 6]
-
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}
「ごみ箱」や「完了したタスク」が表示されている時は、あらたなタスクを追加できないように Todo 入力フォームは無効化しましょう。
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<input
type="text"
value={text}
+ disabled={filter === 'checked' || filter === 'removed'}
onChange={(e) => handleChange(e)}
/>
<input
type="submit"
value="追加"
+ disabled={filter === 'checked' || filter === 'removed'}
onSubmit={handleSubmit}
/>
</form>
この章のソースコード全文
App.tsx
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
removed: false,
};
setTodos((todos) => [newTodo, ...todos]);
setText('');
};
const handleEdit = (id: number, value: string) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, value };
}
return todo;
});
return newTodos;
});
};
const handleCheck = (id: number, checked: boolean) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, checked };
}
return todo;
});
return newTodos;
});
};
const handleRemove = (id: number, removed: boolean) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, removed };
}
return todo;
});
return newTodos;
});
};
const handleFilter = (filter: Filter) => {
setFilter(filter);
};
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) => handleFilter(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();
handleSubmit();
}}
>
<input
type="text"
value={text}
disabled={filter === 'checked' || filter === 'removed'}
onChange={(e) => handleChange(e)}
/>
<input
type="submit"
value="追加"
disabled={filter === 'checked' || filter === 'removed'}
onSubmit={handleSubmit}
/>
</form>
<ul>
{filteredTodos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
disabled={todo.removed}
checked={todo.checked}
onChange={() => handleCheck(todo.id, !todo.checked)}
/>
<input
type="text"
disabled={todo.checked || todo.removed}
value={todo.value}
onChange={(e) => handleEdit(todo.id, e.target.value)}
/>
<button onClick={() => handleRemove(todo.id, !todo.removed)}>
{todo.removed ? '復元' : '削除'}
</button>
</li>
);
})}
</ul>
</div>
);
};
15. ごみ箱を空にする機能を追加する
フィルターで「ごみ箱」の Todo リストを表示しているときには、削除済みタスクを完全に消去できるよう機能追加しましょう。
「ゴミ箱を空にする」ボタンの作成
フィルターが「ごみ箱」の場合は「ゴミ箱を空にする」ボタンを表示し、それ以外のときは従前の Todo 入力フォームを表示するよう改修します。
また、フィルターが「完了済みのタスク」であるときに無効化した Todo 入力フォームを表示する意味が無くなったのでこれも非表示にしましょう。
<option value="removed">ごみ箱</option>
</select>
{/* フィルターが `removed` のときは「ごみ箱を空にする」ボタンを表示 */}
{filter === 'removed' ? (
<button onClick={() => console.log('remove all')}>
ごみ箱を空にする
</button>
) : (
// フィルターが `checked` でなければ Todo 入力フォームを表示
filter !== 'checked' && (
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<input
type="text"
value={text}
disabled={filter === 'checked' || filter === 'removed'}
onChange={(e) => handleChange(e)}
/>
<input
type="submit"
value="追加"
disabled={filter === 'checked' || filter === 'removed'}
onSubmit={handleSubmit}
/>
</form>
)
)}
<ul>
{/* 省略 */}
こうなると Todo 入力フォームが描画される場合には filter === 'removed'
や filter === 'checked'
という状態が発生し得ないので、Todo 入力フォームからこれらを削除しなければいけません。
VS Code 上でのエラー表示
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleChange(e)} />
<input type="submit" value="追加" onSubmit={handleSubmit} />
</form>
「ゴミ箱を空にする」関数の作成と紐付け
todos ステート
配列から removed
フラグが立っている要素を取り除くのみなので、これまでと同様のパターンで処理すれば良いでしょう。
const handleEmpty = () => {
// シャローコピーで事足りる
setTodos((todos) => todos.filter((todo) => !todo.removed));
};
{filter === 'removed' ? (
// クリックイベントに handleEmpty() を渡す
<button onClick={handleEmpty}>ゴミ箱を空にする</button>
) : (
また、ゴミ箱が空の場合、つまり removed
フラグが立っているタスクが todos ステート
配列に存在しない場合にはボタンを無効化します。
<button
onClick={handleEmpty}
disabled={todos.filter((todo) => todo.removed).length === 0}
>
ゴミ箱を空にする
</button>
ここまでで第 1 章に掲げたサンプルと同等の機能をもった Todo アプリが完成しました。
次章では TypeScript ならではの機能を活かし、冗漫なコードの取り纏めに着手します。
この章のソースコード全文
App.tsx
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
removed: false,
};
setTodos((todos) => [newTodo, ...todos]);
setText('');
};
const handleEdit = (id: number, value: string) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, value };
}
return todo;
});
return newTodos;
});
};
const handleCheck = (id: number, checked: boolean) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, checked };
}
return todo;
});
return newTodos;
});
};
const handleRemove = (id: number, removed: boolean) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, removed };
}
return todo;
});
return newTodos;
});
};
const handleFilter = (filter: Filter) => {
setFilter(filter);
};
const handleEmpty = () => {
setTodos((todos) => todos.filter((todo) => !todo.removed));
};
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) => handleFilter(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={handleEmpty}
disabled={todos.filter((todo) => todo.removed).length === 0}
>
ごみ箱を空にする
</button>
) : (
filter !== 'checked' && (
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleChange(e)} />
<input type="submit" value="追加" onSubmit={handleSubmit} />
</form>
)
)}
<ul>
{filteredTodos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
disabled={todo.removed}
checked={todo.checked}
onChange={() => handleCheck(todo.id, !todo.checked)}
/>
<input
type="text"
disabled={todo.checked || todo.removed}
value={todo.value}
onChange={(e) => handleEdit(todo.id, e.target.value)}
/>
<button onClick={() => handleRemove(todo.id, !todo.removed)}>
{todo.removed ? '復元' : '削除'}
</button>
</li>
);
})}
</ul>
</div>
);
};
16. TypeScript のジェネリクスを使ってよく似た関数を一つにまとめる
よく似た関数
第 11 章~第 13 章で作成したイベント処理関数は、どれもロジックが同じで見た目もよく似通ったものでした。
const handleEdit = (id: number, value: string) => {
// ...
if (todo.id === id) return { ...todo, value };
// ...
};
const handleCheck = (id: number, checked: boolean) => {
// ...
if (todo.id === id) return { ...todo, checked };
// ...
};
const handleRemove = (id: number, removed: boolean) => {
// ...
if (todo.id === id) return { ...todo, removed };
// ...
};
これらの冗長な記述を一つの関数にまとめる手段として、TypeScript のジェネリクスという機能が利用できます。
ジェネリクス
any型
を使えば上の 3 つの関数で同一のコードを使い回すことができますが、それではせっかくの型安全性が損なわれてしまいます。
ジェネリクスを使うことでこの問題を解決できます。ジェネリクスとは、一言で言えば「型も変数のように扱う」機能です。
例として、以下の 2 つの関数を見てみましょう。
const getId = (arg: number): number => arg;
const getName = (arg: string): string => arg;
getId(13); // --> 13
getName("Taro"); // --> 'Taro'
まったく同じロジック(与えられた引数をそのまま返しているだけ)であるにも関わらず、引数と戻り値の型が異なるためだけに 2 つの関数が必要となっています。
これを「型も変数 (T)
のように扱う」ように書き換えると以下のようになります。
const getGeneric = <T>(arg: T): T => arg;
<>
内に引数の型(「型引数」と言います)T型
を設定し、戻り値の型注釈にも同じく T型
を与えています。
こうすれば、どの型でも同じコードが使えるようになります。
getGeneric(13); // --> 13
getGeneric("Taro"); // --> 'Taro'
3 つの関数をジェネリクスでリファクタリングする
上の 3 つの関数の呼び出し側を見てみます。
return (
<li key={todo.id}>
<input
type="checkbox"
disabled={todo.removed}
checked={todo.checked}
+ onChange={() => handleCheck(todo.id, !todo.checked)}
/>
<input
type="text"
disabled={todo.checked || todo.removed}
value={todo.value}
+ onChange={(e) => handleEdit(todo.id, e.target.value)}
/>
+ <button onClick={() => handleRemove(todo.id, !todo.removed)}>
{todo.removed ? '復元' : '削除'}
</button>
</li>
);
いずれも todo.id
プロパティ(number型
)を第 1 引数に、書き換えるプロパティの値を第 2 引数にとっています。
このパターンを「型も変数のように扱う」関数 (=handleTodo
) に書き換えることを考えます。
const handleTodo = <K: '書き換えたいプロパティ', V: '新しい値'>(
id: number,
key: K,
value: V
) => {
/** ... */
};
Todo型
オブジェクトの書き換え対象プロパティ
TypeScript では extends
キーワードを用いることで、型引数 K
, V
を特定の型に限定することができます。
オブジェクトのプロパティは、keyof
演算子で取得します。
K extends keyof Todo
// => 'id', 'value', 'checked', 'removed' のいずれか
新しい値
新しい値の型変数名は V型
とし、ブラケット記法を利用して、対象となるプロパティへ V型
の新しい値を代入します。
V extends Todo[K]
// => todo.id, todo.value, todo.checked, todo.removed のいずれかの値
まとめ
では、handleTodo
関数を完成させましょう。
const handleTodo = <K extends keyof Todo, V extends Todo[K]>(
id: number,
key: K,
value: V
) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, [key]: value };
} else {
return todo;
}
});
return newTodos;
});
};
return { ...todo, [key]: value }
の部分では、まず書き換え対象でないプロパティをいままで通りスプレッド構文で元の todo 要素からコピー・展開し、書き換え対象のプロパティを計算プロパティ名を使って特定し、引数の値 value
を代入しています。
計算プロパティ名では、大カッコ []
の中へ式を記述することができ、それが計算されてプロパティ名として使用されます。
[key]: // --> K型 == id, value, checked, removed のいずれか
では、この関数を使って呼び出し側もアップデートしましょう。
return (
<li key={todo.id}>
<input
type="checkbox"
disabled={todo.removed}
checked={todo.checked}
+ onChange={() => handleTodo(todo.id, 'checked', !todo.checked)}
/>
<input
type="text"
disabled={todo.checked || todo.removed}
value={todo.value}
+ onChange={(e) => handleTodo(todo.id, 'value', e.target.value)}
/>
<button
+ onClick={() => handleTodo(todo.id, 'removed', !todo.removed)}
>
{todo.removed ? '復元' : '削除'}
</button>
</li>
);
これで重複していた handleEdit
、handleCheck
、そして handleRemove
の 3 つの関数を一つにまとめることができました。
この章のソースコード全文
App.tsx
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
removed: false,
};
setTodos((todos) => [newTodo, ...todos]);
setText('');
};
const handleTodo = <K extends keyof Todo, V extends Todo[K]>(
id: number,
key: K,
value: V
) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, [key]: value };
} else {
return todo;
}
});
return newTodos;
});
};
const handleFilter = (filter: Filter) => {
setFilter(filter);
};
const handleEmpty = () => {
setTodos((todos) => todos.filter((todo) => !todo.removed));
};
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) => handleFilter(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={handleEmpty}
disabled={todos.filter((todo) => todo.removed).length === 0}
>
ごみ箱を空にする
</button>
) : (
filter !== 'checked' && (
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleChange(e)} />
<input type="submit" value="追加" onSubmit={handleSubmit} />
</form>
)
)}
<ul>
{filteredTodos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
disabled={todo.removed}
checked={todo.checked}
onChange={() => handleTodo(todo.id, 'checked', !todo.checked)}
/>
<input
type="text"
disabled={todo.checked || todo.removed}
value={todo.value}
onChange={(e) => handleTodo(todo.id, 'value', e.target.value)}
/>
<button
onClick={() => handleTodo(todo.id, 'removed', !todo.removed)}
>
{todo.removed ? '復元' : '削除'}
</button>
</li>
);
})}
</ul>
</div>
);
};
17. ブラウザのストレージへデータを保存する ~ useEffect フックの利用"
ブラウザのストレージ (IndexedDB) を利用し、タスクの現在の状態を保存することでページの再読み込み後も Todo リストが消えないようにします。
LocalForage のインストール
LocalForage は、ローカルストレージ風のシンプルな API で非同期ストレージ(IndexedDB または WebSQL)を利用可能とするライブラリですが、今回はこのツールを使って IndexedDB にデータを保存します。
npm install localforage
LocalForage の使い方
LocalForage の使い方には、引数にコールバック関数を渡す方法と Promise を使う方法の 2 つがあります。本書では Promise による方法を採用します。
// データを取得
localforage
.getItem('データベースのキー名')
// value: 保存されている値
.then((value) => console.log(JSON.stringify(values)));
// データ (=value) を保存
localforage.setItem('データベースのキー名', value);
LocalForage では、保存する値として文字列や数値だけでなく、配列やオブジェクトも扱えます。
つまりこの Todo アプリでは、 todos
ステート(todo型
オブジェクトの配列)を渡すことになります。
useEffect フックで React コンポーネントへ組み込む
React Hooks では、関数コンポーネント内で副作用 (サイドエフェクト) を実行するための useEffect というフックが用意されています。
副作用とは、関数コンポーネントの出力(=レンダリング)に関係ない処理のことです。 つまり、 useEffect を用いることでレンダリングと副作用を切り離すことが可能になります。
典型的には、React が DOM を更新した後で何らかの追加のコードを実行したいという場合にこの useEffect フックを使います。
useEffect フックの構文
useEffect(() => console.log('TODO!'), []);
第 1 引数のコールバック関数
React コンポーネントがマウント(描画)またはアップデート(更新・再描画)されたあとに実行したい何らかの処理を指定します。
第 2 引数の配列
useEffect 内で参照している外部の変数や関数を配列内に列挙します(「依存配列」 と呼ばれます)。
useEffectフックでは、この依存配列内のいずれかの要素が作成・更新されたときに第 1 引数の処理を実行します。
空の配列とするとコンポーネントがマウントされたときのみに第 1 引数の処理を実行します。また、この配列そのものを省略すると常に副作用が実行されます。
useEffect(() => {
console.log(`コンポーネントがマウントされたよ!`);
}, []);
useEffect(() => {
console.log(`filter ステートが更新されたよ!: ${filter}`);
}, [filter]);
依存配列の重要性
依存配列への要素の指定には注意が必要です。
なぜなら、これへ適切な要素が指定されていなかったり、この配列そのものを、そうすべきでないときに省略したりすると、副作用が意図した通りに実行されなかったり、React コンポーネントが無限ループに陥るなどのバグを生じさせてしまうからです(※)。
しかし、現在の Vite では最初から eslint-plugin-react-hooks が設定済みであるため、ESLint が依存配列の指定漏れを警告してくれるので、これを活用しましょう。
VS Code で依存配列内の黄色の下線にホバー
useEffect フックが依存する 'count' が指定されていません。
これを依存配列に含めるか、依存配列そのものを削除してください。
useEffect フックの実装
では、App.tsx
へサイドエフェクト処理を追加してみましょう。
// localforage をインポート
import localforage from 'localforage';
// useEffect フックをインポート
import { useEffect, useState } from 'react';
なお、useEffect フックはコンポーネントの内側で定義しなければいけません。典型的には return 文の直前に配置することになります。
/**
* キー名 'todo-20200101' のデータを取得
* 第 2 引数の配列が空なのでコンポーネントのマウント時のみに実行される
*/
useEffect(() => {
localforage
.getItem('todo-20200101')
.then((values) => setTodos(values as Todo[]));
}, []);
/**
* todos ステートが更新されたら、その値を保存
*/
useEffect(() => {
localforage.setItem('todo-20200101', todos);
}, [todos]);
ブラウザの IndexedDB の状態は、開発者ツールの Application -> Storage -> IndexedDB -> localforage からも確認できます。
アプリケーション -> ストレージ -> IndexedDB -> localforage
本章のソースコード全文
App.tsx
import localforage from "localforage";
import { useState, useEffect } 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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
removed: false,
};
setTodos((todos) => [newTodo, ...todos]);
setText("");
};
const handleTodo = <K extends keyof Todo, V extends Todo[K]>(
id: number,
key: K,
value: V,
) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, [key]: value };
} else {
return todo;
}
});
return newTodos;
});
};
const handleFilter = (filter: Filter) => {
setFilter(filter);
};
const handleEmpty = () => {
setTodos((todos) => todos.filter((todo) => !todo.removed));
};
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;
}
});
useEffect(() => {
localforage
.getItem("todo-20200101")
.then((values) => setTodos(values as Todo[]));
}, []);
useEffect(() => {
localforage.setItem("todo-20200101", todos);
}, [todos]);
return (
<div>
<select
defaultValue="all"
onChange={(e) => handleFilter(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={handleEmpty}
disabled={todos.filter((todo) => todo.removed).length === 0}
>
ごみ箱を空にする
</button>
) : (
filter !== "checked" && (
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleChange(e)} />
<input type="submit" value="追加" onSubmit={handleSubmit} />
</form>
)
)}
<ul>
{filteredTodos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
disabled={todo.removed}
checked={todo.checked}
onChange={() => handleTodo(todo.id, "checked", !todo.checked)}
/>
<input
type="text"
disabled={todo.checked || todo.removed}
value={todo.value}
onChange={(e) => handleTodo(todo.id, "value", e.target.value)}
/>
<button
onClick={() => handleTodo(todo.id, "removed", !todo.removed)}
>
{todo.removed ? "復元" : "削除"}
</button>
</li>
);
})}
</ul>
</div>
);
};
18. ストレージのデータにも型安全性を担保する
前章の例では、setTodos
するためにストレージのデータに対して、型アサーション as Todos
を利用しています。しかし厳密に型安全を担保するのであれば、ストレージのデータもそれが Todo型
オブジェクトの配列であることを事前に確認すべきです。
そのためには、ユーザー定義の型ガード関数を用意する必要があります。
型エイリアスを型宣言ファイルとして書き出す
App.tsx
内で宣言されている Todo
と Filter
の 2 つの型エイリアスを、他のファイル(=モジュールやコンポーネント)からも参照できるようにするため、型定義ファイルとして書き出します。
src
ディレクトリ内へ @types
ディレクトリを作成し、その中へそれぞれ Todo.d.ts
、Filter.d.ts
として配置します。
基本的には、上記の型エイリアスへ declare
キーワード(アンビエント宣言)を加えて型として宣言するだけです。
declare type Todo = {
value: string;
readonly id: number;
checked: boolean;
removed: boolean;
};
declare type Filter = 'all' | 'checked' | 'unchecked' | 'removed';
ユーザー定義の型ガード関数を作成する
src
フォルダ以下へ lib
というフォルダを作成し、isTodos.ts
を配置します。
/* eslint-disable @typescript-eslint/no-explicit-any */
const isTodo = (arg: any): arg is Todo => {
return (
typeof arg === 'object' &&
Object.keys(arg).length === 4 &&
typeof arg.id === 'number' &&
typeof arg.value === 'string' &&
typeof arg.checked === 'boolean' &&
typeof arg.removed === 'boolean'
);
};
export const isTodos = (arg: any): arg is Todo[] => {
return Array.isArray(arg) && arg.every(isTodo);
};
上段の isTodo
関数では、与えられた引数が Todo型
のオブジェクトであるかどうかをチェックしています。
:arg is Todo
の部分は Type predicate と呼ばれ、boolean
を返す関数の戻り値を x is T
と書くことで、 true
を返せば x
が T型
、 false
を返せばそうでないことを表す機能です。
下段のエクポートされた isTodos
関数では、与えられた引数が、配列 かつ その各要素が Todo型
オブジェクト であるか否かをチェックしています。
Array.every()
メソッドは、配列内のすべての要素が指定された関数で実装されたテストに合格するかどうかをテストし、論理値を返します。
では、この isTodos.ts
ファイルから型ガード関数をインポートして型チェックを適用しましょう。
import { isTodos } from './lib/isTodos';
// 中略
useEffect(() => {
localforage
.getItem('todo-20200101')
.then((values) => isTodos(values) && setTodos(values));
}, []);
本章のソースコード全文
Todo.d.ts
declare type Todo = {
value: string;
readonly id: number;
checked: boolean;
removed: boolean;
};
Filter.d.ts
type Filter = "all" | "checked" | "unchecked" | "removed";
isTodos.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
const isTodo = (arg: any): arg is Todo => {
return (
typeof arg === 'object' &&
Object.keys(arg).length === 4 &&
typeof arg.id === 'number' &&
typeof arg.value === 'string' &&
typeof arg.checked === 'boolean' &&
typeof arg.removed === 'boolean'
);
};
export const isTodos = (arg: any): arg is Todo[] => {
return Array.isArray(arg) && arg.every(isTodo);
};
App.tsx
import localforage from "localforage";
import { useState, useEffect } from "react";
import { isTodos } from "./lib/isTodos";
export const App = () => {
const [text, setText] = useState("");
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<Filter>("all");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleSubmit = () => {
if (!text) return;
const newTodo: Todo = {
value: text,
id: new Date().getTime(),
checked: false,
removed: false,
};
setTodos((todos) => [newTodo, ...todos]);
setText("");
};
const handleTodo = <K extends keyof Todo, V extends Todo[K]>(
id: number,
key: K,
value: V,
) => {
setTodos((todos) => {
const newTodos = todos.map((todo) => {
if (todo.id === id) {
return { ...todo, [key]: value };
} else {
return todo;
}
});
return newTodos;
});
};
const handleFilter = (filter: Filter) => {
setFilter(filter);
};
const handleEmpty = () => {
setTodos((todos) => todos.filter((todo) => !todo.removed));
};
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;
}
});
useEffect(() => {
localforage
.getItem("todo-20200101")
.then((values) => isTodos(values) && setTodos(values));
}, []);
useEffect(() => {
localforage.setItem("todo-20200101", todos);
}, [todos]);
return (
<div>
<select
defaultValue="all"
onChange={(e) => handleFilter(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={handleEmpty}
disabled={todos.filter((todo) => todo.removed).length === 0}
>
ごみ箱を空にする
</button>
) : (
filter !== "checked" && (
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<input type="text" value={text} onChange={(e) => handleChange(e)} />
<input type="submit" value="追加" onSubmit={handleSubmit} />
</form>
)
)}
<ul>
{filteredTodos.map((todo) => {
return (
<li key={todo.id}>
<input
type="checkbox"
disabled={todo.removed}
checked={todo.checked}
onChange={() => handleTodo(todo.id, "checked", !todo.checked)}
/>
<input
type="text"
disabled={todo.checked || todo.removed}
value={todo.value}
onChange={(e) => handleTodo(todo.id, "value", e.target.value)}
/>
<button
onClick={() => handleTodo(todo.id, "removed", !todo.removed)}
>
{todo.removed ? "復元" : "削除"}
</button>
</li>
);
})}
</ul>
</div>
);
};
Discussion
わかりやすい記事ありがとうございます!
とても勉強になります。
一点だけ気になった点がございます。
フィルターをかける際のコールバック関数名称は以下の方が良いと思いました
handleSort
↓
handleFilterChange
sort は一般的に並び替えの意味なので、並び替え機能に使われます。
ご指摘の通りだと思います、ありがとうございます。
関数名を
handleFilter()
に修正しました。早速なご対応ありがとうございます。
分かりやすい記事をありがとうございました。初心者の私でもハンズオンで最後までできました。
私の環境("react": "^18.3.1")では、
handleEmptyやその他のメソッドで、始めのtodosがあるとステートとして認識されませんでした。
以下のように引数をなくすと、正常に動作しました。
コメントありがとうございます。
「始めの」は更新関数の引数のことだと思いますが妙ですね。
私も同じ環境(↓)でやってみましたが問題なく動いています。原因は分かりません。
早速返信を下さりありがとうございました。package.jsonを比較してみたのですが、特に違いはありませんでした。