🔰

React と TypeScript で簡単 TODO アプリ

2024/04/16に公開
6

1. はじめに

この記事では、React 16.8 で導入された Hooks の機能を使って、ハンズオン形式で To Do リストを管理するためのアプリケーション(以下、Todo アプリ)を作成していきます。

また、現在のフロントエンド開発環境でデファクト・スタンダードとなりつつある TypeScript を利用することによって型安全な実装の実現も目指します。

https://www.typescriptlang.org/ja/

https://typescript-jp.gitbook.io/deep-dive/

2. 開発環境の準備

Vite.js で React プロジェクトを作成する

フロントエンドツール Vite.js(以下、Vite)を利用して React + TypeScript のプロジェクトを作成します。

https://ja.vitejs.dev/

ターミナルで以下のコマンドを実行します。

zsh
npm create vite
  1. そのまま Enter キーを押します。


プロジェクトに名前をつける

  1. React を選択します。


フレームワークの選択

  1. TypeScript を選択します。


JavsScript または TypeScript の選択

zsh
% 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

  1. Vite の指示に従い、さっそくこのプロジェクトを起動してみましょう。
zsh
% cd vite-project
% npm install
% npm run dev


Vite.js ローカルサーバーが起動

この画面で o + Enter をタイプするか、ブラウザで http://localhost:5173 にアクセスすると React アプリが表示されます。


h + Enter をタイプしてヘルプを表示

React デベロッパーツールの準備

Google ChromeMozilla Firefox、または Microsoft Edge には React Developer Tools という拡張機能がそれぞれ用意されています。

https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=ja

これは、React コンポーネントの状態をブラウザの開発者ツール画面に表示してくれる拡張機能です。必ずインストールしておきましょう。

開発者ツールを表示するには、キーボードから Ctrl + Shift + I を打鍵します(※)。Components タブからコンポーネント名(ここでは App)を選択すると React コンポーネントの状態を確認できます。

ホットリロード機能の確認

VS Code でプロジェクトフォルダを開き、src/App.tsx を編集してみましょう。

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.tsxApp.tsx そして index.css の 3 つのファイルを、以下のコードで置き換えてください。

src/main.tsx
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>
);
src/App.tsx
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>
  );
};
src/index.css
#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 ルートとしています。

index.html
<body>
  <div id="root"></div>
  <!-- 省略 -->
</body>
src/main.tsx
const root = createRoot(document.getElementById('root') as Element);

そして、その React ルートが持つ render メソッドは、DOM 内部へ React 要素である <App /> コンポーネントをレンダリングします。

src/main.tsx
root.render(
  <StrictMode>
    <App />
  </StrictMode>
);

https://ja.react.dev/reference/react-dom/client

as Element

createRoot の引数内では、取得した HTML 要素を as Element として型アサーションしています。型アサーションとは、型推論を上書きして別の型を割り当てる機能です。

// Element 型としてアサーション
createRoot(document.getElementById("root") as Element);

https://typescriptbook.jp/reference/values-types-variables/type-assertion-as

もしも、この as Element が無いとどうなるでしょうか?

型 'HTMLElement | null' の引数を型 'Element | DocumentFragment' のパラメーターに割り当てることはできません。

React アプリをマウントする HTML ファイルに root という id を持った要素が存在しない (= null) という事態は十分にあり得ます。
そのため、TypeScript の世界では document.getElementById から得られる値は、常に HTMLElement 型または Null 型 となります。

createRoot は、その引数に Element 型もしくは DocumentFragment 型の値しか受け取れないので、null となる可能性を持つ値を渡すことは出来ません。そのため、ここでは Element 型へと型アサーションしているのです。

別解として、null ではないことを保証する Non-null アサーション演算子: ! を利用する方法もあります。

Non-null アサーションの例
// "!" 演算子で null でないことを保証する
createRoot(document.getElementById('root')!);

https://developer.mozilla.org/ja/docs/Web/JavaScript/Inheritance_and_the_prototype_chain

App.tsx

App コンポーネント内では、JSX で作成した Todo 入力フォームを return しています。

src/App.tsx
export const App = () => {
  return (
    <div>
      <form onSubmit={(e) => e.preventDefault()}>
        {/* 省略 */}
      </form>
    </div>
  );
};

https://ja.react.dev/learn/writing-markup-with-jsx

JSX (JavaScript XML) は、JavaScript 内で HTML に似た文法で UI を直感的に組み立てることを可能とします。
この時点での JSX に関する注意点は以下の 2 つです。

  1. 必ず単一の JSX 要素(上の例では <div>~</div>)を return する必要があります。
    以下のような例ではエラーとなってしまいます。
// ❌ ダメな例
const Example = () => {
  // 単一の JSX 要素でないためエラー
  return (
    <p>最初の行</p>
    <p>次の行</p>
  )
}

JSX 要素を単一にラップするためだけに <div> 要素などを使いたくない場合は、代わりに JSX フラグメント <> ~ </> を使って JSX 文を囲ってください。

// 🟢 OK!
const Example = () => {
  return (
    <>
      <p>最初の行</p>
      <p>次の行</p>
    </>
  )
}
  1. JSX 内での onsubmit イベントは、onSubmitロワー・キャメルケース)として記述する必要があります。
    これは、その他の onClickonChange といったイベントリスナーでも同様です。
<input type="text" value="" onChange={(e) => e.preventDefault()} />

これから

以降、この関数コンポーネント App をベースとして Todo アプリを作成していきます。

いまのところ onSubmitonChange などのイベントリスナーでは、イベントが発生しても preventDefault() してしまっているので特に何も起きません。

https://developer.mozilla.org/ja/docs/Web/API/Event/preventDefault

4. フォームに入力された文字列を状態 (=state) として保持する

useState フックの導入

フォームに入力された文字列を 状態 (=ステート) として保持するため、useState フックを導入します。
React がコンポーネントのステートに応じて DOM をリアクティブに更新する様子をチェックしましょう。

useState フックの構文

React では、コンポーネントに状態を「記憶」させるための useState という特別な関数が用意されています。

構文
const [text, setText] = React.useState('hello');
  • useState:
    • 引数となるのはステートの初期値 (= 'hello') です
    • 現在のステート text と、それを更新するための関数 setText とをペアにした配列を返します
  • text: 現在のステートの値が格納された変数です
  • setText: ステートを更新するメソッドです

https://ja.react.dev/reference/react/useState

text ステートを作成

App コンポーネントへ text ステートの値を「記憶」させ、その値に応じてリアクティブに DOM を書き換えましょう。

src/App.tsx
// 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 キーワードを使って定義します。

App.tsx
// "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[] となります。

App.tsx
export const App = () => {
  const [text, setText] = useState('');
  // 追加
  const [todos, setTodos] = useState<Todo[]>([]);

  return (<div>{/* ... */}</div>)

useState<> の中に型を指定しておくと、これと型が異なる値をステートに代入できなくなるため、ステートの型安全性が常に保証されます。

https://typescript-jp.gitbook.io/deep-dive/getting-started/why-typescript

上記のコードで言うと、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() メソッドが「ミュータブルな操作」、スプレッド構文が「イミュータブルな操作」です。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/push

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

なぜ React ではイミュータブルな操作が必要なのか?

では、なぜ React ではイミュータブルな操作が必要とされるのでしょうか?

パフォーマンス上の理由や再レンダリングについての予測可能性のため、React はコンポーネントの変化をオブジェクトの同一性(差分)チェックで検知しています。

ミュータブルな操作をしてしまうとコピー元の情報も変更されてしまうため、変更前と変更後の差分を React が検知できなくなってしまいます。

一方、イミュータブルな操作では変更前と変更後の情報をそれぞれ参照しているので、React は差分を検知できます。
また、元のステートが変更されていないので、それを履歴として残すことでその段階まで巻き戻すような機能の実装も可能となります。

ふたたび上でも挙げた例で言うと、todos ステートへの以下のような操作はいけません。

// ❌ やってはいけない
// 元配列を変更するかたちでステートを更新
const newTodos = todos.unshift({ value: "新しいタスク" });
setTodos(newTodos);

なぜなら、Array.prototype.push()Array.prototype.unshift() は破壊的メソッドなので todos ステートを直接ミューテート(mutate, 書き換え)してしまうからです。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/unshift

配列のステートを操作する場合には、 いったんそのコピーに対して変更を加え、その変更後のコピーでステートを更新します。

コピーを操作
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 が検知できるようになります。


コピーに変更を加えても元配列には影響がない

参考記事

7. イベントを処理する関数を作成する

コールバック関数の作成

それでは、todos ステートを更新、つまり新しいタスクの追加をしていきましょう。
イベントを処理する関数(=イベントハンドラー)を作成し、それを実行することでステートを更新します。

src/App.tsx
  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 を追加した新たな配列を作成していることになります。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

また、ここでは 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() と記述すると即時実行されてしまうため用をなしません

https://sbfl.net/blog/2019/02/08/javascript-callback-func/

なお、<form> タグの中でいったん e.preventDefault() しているのは Enter キー打鍵でページそのものがリロードされてしまうのを防ぐためです。

src/App.tsx
  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() として書き出しましょう。

src/App.tsx
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };
src/App.tsx
<input type="text" value={text} onChange={(e) => handleChange(e)} />

イベントの型を調べる

イベントの型がわからない時は、VS Code であればイベント上でマウスカーソルをホバーさせるとポップアップが表示されます。

この章のソースコード全文

App.tsx
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() メソッドは、与えられた関数を配列のすべての要素に対して呼び出し、その結果からなる新しい配列を生成します。

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);
}

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map

src/App.tsx
    <div>
      <form onSubmit={/* 省略 */}>
        {/* 省略 */}
      </form>
      <ul>
        {todos.map((todo) => {
          return <li>{todo.value}</li>;
        })}
      </ul>
    </div>

ただし、これだけでは各 <li>key プロパティが設定されていないため、以下のような警告が表示されてしまいます。

チュートリアル:React の導入 - key を選ぶ(公式)

警告:リストのそれぞれの要素は一意な key プロパティを持っているべきです

リストをレンダーするときの key の重要性

なぜリストの各項目に key プロパティが必要となるのでしょうか?

React はリストをレンダーする際、どのアイテムが変更になったのか特定できる必要があります。リストのアイテムは追加された可能性も、削除された可能性も、並び替えられた可能性も、中身自体が変更になった可能性もあるからです。

変更・追加・削除・並び替えを検知するためには、リストの各項目を特定する一意な識別子が必要です。

この一意な識別子こそが key プロパティであり、上の警告は『各項目を特定できないため、リストに変更が加えられても正しく再レンダーできない可能性があります』という意味で表示されているのです。

https://ja.react.dev/learn/rendering-lists#rules-of-keys

次章では、この key プロパティを各項目へ与えるため、Todo型オブジェクトの仕様について再考します。

9. Todo(タスク) の仕様を考える (その 2)

Todo型オブジェクトへプロパティを追加する

前章で見た通り、todos ステート配列をリストとして展開するためには、配列の各要素へその識別子を持たせる必要があります。

配列の各要素、つまり Todo型のタスクそれぞれに一意な key を持たせる必要が生じたため、Todo型そのものを拡張しなければなりません。

ここでは、id プロパティとして一意な数字 (number型) を持たせることにします。
また、一意であるはずの識別子が書き換えられてはならないため、readonly(読み取り専用) のプロパティとします。

src/App.tsx
  type Todo = {
    value: string;
+   readonly id: number;
  };

https://typescript-jp.gitbook.io/deep-dive/type-system/readonly

Todo型オブジェクトには id プロパティの指定が必須となったため、handleSubmit() メソッドを更新しなければいけません。

src/App.tsx
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) を付加します。

src/App.tsx
  <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 /> タグでラップします。

src/App.tsx
  <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 が編集されたのか特定するため、その todoid プロパティを引数として受け取る
  • e.target.value(onChange イベントの結果)を書き換え後の todo.value の値とするために第 2 引数として受け取る
  • 編集後の todo を含む Todo型の配列 で todos ステート を更新する
src/App.tsx
  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() は、与えられた関数を配列のすべての要素に対して呼び出し、その結果からなる新しい配列を生成する非破壊的メソッドです。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map

上のコールバック関数を <input onChange={} /> イベントに紐付けます。

src/App.tsx
  <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 段階目であるオブジェクトそのものの追加であったためにシャローコピーによる操作で十分だったのです。

https://developer.mozilla.org/ja/docs/Glossary/Shallow_copy

入れ子になったプロパティを書き替える

元の値をイミュータブルに保ちつつ、オブジェクト内で入れ子になったプロパティを書き換えるには、そのオブジェクトの階層までたどって複製しなければなりません。
具体的には、配列の要素(Todo 型オブジェクト)の階層においてスプレッド構文でコピー・展開したうえで、入れ子のプロパティの値を更新します。

src/App.tsx
  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
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型 となります。

src/App.tsx
type Todo = {
  value: string;
  readonly id: number;
  // 完了/未完了を示すプロパティ
  checked: boolean;
};

Todo型オブジェクトには checked プロパティが必須となったため、 handleSubmit() メソッドを更新します。

src/App.tsx
    if (!text) return;

    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
      // 初期値(todo 作成時)は false
      checked: false,
    };

    setTodos((todos) => [newTodo, ...todos]);

それぞれの項目の前へ、完了/未完了を操作をするためのチェックボックスを置きます。

App.tsx
      <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 がチェックされたのか特定するための idchecked プロパティの値を引数として受け取り、その todo型 オブジェクトの checked プロパティを更新します。

src/App.tsx
  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 のジェネリクスを使ってよく似た関数を一つにまとめる」でのイベント処理関数のリファクタリングが楽になることに気づくでしょう。

src/App.tsx
 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>
 );

このままではチェック済みのタスクも編集できてしまうので、チェック済みの項目は編集用フォームを無効にします。

src/App.tsx
  <input
    type="text"
+   disabled={todo.checked}
    value={todo.value}
    onChange={(e) => handleEdit(todo.id, e.target.value)}
  />

この章のソースコード全文

App.tsx
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型 となります。

src/App.tsx
type Todo = {
  value: string;
  readonly id: number;
  checked: boolean;
  removed: boolean;
};

前章の checked のときと同じく、 handleSubmit() メソッドを更新する必要があります。

src/App.tsx
  if (!text) return;
  const newTodo: Todo = {
    value: text,
    id: new Date().getTime(),
    checked: false,
    removed: false, // <-- 追加
  };

  setTodos((todos) => [newTodo, ...todos]);

削除ボタンの追加

それぞれの項目の後ろへ削除ボタンを追加します。

App.tsx
  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 イベントへ紐付けします。

src/App.tsx
  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 フラグの反転は呼び出し側で行うことに注意しましょう。

src/App.tsx
    <button onClick={() => handleRemove(todo.id, !todo.removed)}>
      {todo.removed ? '復元' : '削除'}
    </button>

削除されたアイテムは改変できないようにするため、チェックボックスと編集用フォームも無効化します。

src/App.tsx
  <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
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 イベントへはとりあえずダミーを与えておきます。

src/App.tsx
    <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種類とします。

src/App.tsx
type Filter = 'all' | 'checked' | 'unchecked' | 'removed';
フィルター タスクの種別
all すべてのタスク(削除済みのタスクをのぞく)
checked 完了したタスク
unchecked 現在の(未完了の)タスク
removed ごみ箱(削除済みのタスク)

前項の <option /> タグの値を Filter型のステート として保持しましょう。

src/App.tsx
export const App = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);
  // 追加
  const [filter, setFilter] = useState<Filter>('all');

コールバック関数の作成とイベントへの紐付け

上のセレクタの値が変化 (onChange イベントの発火)すると filter ステート を更新する関数を作成します。
Filter型の変数を引数に取り、ステートを更新するだけの関数です。

src/App.tsx
const handleFilter = (filter: Filter) => {
  setFilter(filter);
};

上の関数を onChange イベントへ紐づけます。

src/App.tsx
      // 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型オブジェクト内のプロパティを編集するわけではないので、イミュータビリティには影響がない

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/filter

src/App.tsx
  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> タグにフィルタリング済みのリストを渡すように書き換えます。

src/App.tsx
        <ul>
-         {todos.map((todo) => {
+         {filteredTodos.map((todo) => {
            return (
              <li key={todo.id}>
                <input
                  type="checkbox"
                  disabled={todo.removed}

「ごみ箱」や「完了したタスク」が表示されている時は、あらたなタスクを追加できないように Todo 入力フォームは無効化しましょう。

src/App.tsx
    <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
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 入力フォームを表示する意味が無くなったのでこれも非表示にしましょう。

src/App.tsx
      <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 上でのエラー表示

src/App.tsx
    <form
      onSubmit={(e) => {
        e.preventDefault();
        handleSubmit();
      }}
    >
      <input type="text" value={text} onChange={(e) => handleChange(e)} />
      <input type="submit" value="追加" onSubmit={handleSubmit} />
    </form>

「ゴミ箱を空にする」関数の作成と紐付け

todos ステート 配列から removed フラグが立っている要素を取り除くのみなので、これまでと同様のパターンで処理すれば良いでしょう。

src/App.tsx
  const handleEmpty = () => {
    // シャローコピーで事足りる
    setTodos((todos) => todos.filter((todo) => !todo.removed));
  };
src/App.tsx
  {filter === 'removed' ? (
    // クリックイベントに handleEmpty() を渡す
    <button onClick={handleEmpty}>ゴミ箱を空にする</button>
  ) : (

また、ゴミ箱が空の場合、つまり removed フラグが立っているタスクが todos ステート 配列に存在しない場合にはボタンを無効化します。

src/App.tsx
  <button
    onClick={handleEmpty}
    disabled={todos.filter((todo) => todo.removed).length === 0}
  >
    ゴミ箱を空にする
  </button>

ここまでで第 1 章に掲げたサンプルと同等の機能をもった Todo アプリが完成しました。
次章では TypeScript ならではの機能を活かし、冗漫なコードの取り纏めに着手します。

この章のソースコード全文

App.tsx
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 章で作成したイベント処理関数は、どれもロジックが同じで見た目もよく似通ったものでした。

handleEdit
  const handleEdit = (id: number, value: string) => {
    // ...
    if (todo.id === id) return { ...todo, value };
    // ...
  };
handleCheck
  const handleCheck = (id: number, checked: boolean) => {
    // ...
    if (todo.id === id) return { ...todo, checked };
    // ...
  };
handleRemove
  const handleRemove = (id: number, removed: boolean) => {
    // ...
    if (todo.id === id) return { ...todo, removed };
    // ...
};

これらの冗長な記述を一つの関数にまとめる手段として、TypeScript のジェネリクスという機能が利用できます。

https://typescriptbook.jp/reference/generics

ジェネリクス

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 を特定の型に限定することができます。

https://typescriptbook.jp/reference/generics/type-parameter-constraint

オブジェクトのプロパティは、keyof 演算子で取得します。

K extends keyof Todo
// => 'id', 'value', 'checked', 'removed' のいずれか

https://typescriptbook.jp/reference/type-reuse/keyof-type-operator

新しい値

新しい値の型変数名は V型 とし、ブラケット記法を利用して、対象となるプロパティへ V型 の新しい値を代入します。

V extends Todo[K]
// => todo.id, todo.value, todo.checked, todo.removed のいずれかの値

まとめ

では、handleTodo 関数を完成させましょう。

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>
);

これで重複していた handleEdithandleCheck、そして handleRemove の 3 つの関数を一つにまとめることができました。

この章のソースコード全文

App.tsx
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 リストが消えないようにします。

https://developer.mozilla.org/ja/docs/Web/API/IndexedDB_API/Using_IndexedDB

LocalForage のインストール

LocalForage は、ローカルストレージ風のシンプルな API で非同期ストレージ(IndexedDB または WebSQL)を利用可能とするライブラリですが、今回はこのツールを使って IndexedDB にデータを保存します。

zsh
npm install localforage

https://www.npmjs.com/package/localforage

LocalForage の使い方

LocalForage の使い方には、引数にコールバック関数を渡す方法と Promise を使う方法の 2 つがあります。本書では Promise による方法を採用します。

https://jsprimer.net/basic/async/

構文
// データを取得
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 フックを使います。

https://ja.react.dev/reference/react/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 へサイドエフェクト処理を追加してみましょう。

src/App.tsx
// localforage をインポート
import localforage from 'localforage';

// useEffect フックをインポート
import { useEffect, useState  } from 'react';

なお、useEffect フックはコンポーネントの内側で定義しなければいけません。典型的には return 文の直前に配置することになります。

src/App.tsx
  /**
   * キー名 '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
src/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型オブジェクトの配列であることを事前に確認すべきです。

そのためには、ユーザー定義の型ガード関数を用意する必要があります。

https://typescriptbook.jp/reference/functions/type-guard-functions

型エイリアスを型宣言ファイルとして書き出す

App.tsx 内で宣言されている TodoFilter の 2 つの型エイリアスを、他のファイル(=モジュールやコンポーネント)からも参照できるようにするため、型定義ファイルとして書き出します。

https://typescript-jp.gitbook.io/deep-dive/type-system/intro/d.ts

src ディレクトリ内へ @types ディレクトリを作成し、その中へそれぞれ Todo.d.tsFilter.d.ts として配置します。

基本的には、上記の型エイリアスへ declare キーワード(アンビエント宣言)を加えて型として宣言するだけです。

src/@types/Todo.d.ts
declare type Todo = {
  value: string;
  readonly id: number;
  checked: boolean;
  removed: boolean;
};
src/@types/Filter.d.ts
declare type Filter = 'all' | 'checked' | 'unchecked' | 'removed';

ユーザー定義の型ガード関数を作成する

src フォルダ以下へ lib というフォルダを作成し、isTodos.ts を配置します。

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 を返せば xT型false を返せばそうでないことを表す機能です。

下段のエクポートされた isTodos 関数では、与えられた引数が、配列 かつ その各要素が Todo型オブジェクト であるか否かをチェックしています。
Array.every() メソッドは、配列内のすべての要素が指定された関数で実装されたテストに合格するかどうかをテストし、論理値を返します。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/every

では、この isTodos.ts ファイルから型ガード関数をインポートして型チェックを適用しましょう。

src/App.tsx
import { isTodos } from './lib/isTodos';

// 中略

  useEffect(() => {
    localforage
      .getItem('todo-20200101')
      .then((values) => isTodos(values) && setTodos(values));
  }, []);

本章のソースコード全文

Todo.d.ts
src/@types/Todo.d.ts
declare type Todo = {
  value: string;
  readonly id: number;
  checked: boolean;
  removed: boolean;
};
Filter.d.ts
src/@types/Filter.d.ts
type Filter = "all" | "checked" | "unchecked" | "removed";
isTodos.ts
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);
};
App.tsx
src/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>
  );
};
GitHubで編集を提案

Discussion

takaya314takaya314

わかりやすい記事ありがとうございます!
とても勉強になります。

一点だけ気になった点がございます。
フィルターをかける際のコールバック関数名称は以下の方が良いと思いました

handleSort

handleFilterChange

sort は一般的に並び替えの意味なので、並び替え機能に使われます。

はっぱはっぱ

ご指摘の通りだと思います、ありがとうございます。
関数名を handleFilter() に修正しました。

kota0716kota0716

分かりやすい記事をありがとうございました。初心者の私でもハンズオンで最後までできました。

私の環境("react": "^18.3.1")では、
handleEmptyやその他のメソッドで、始めのtodosがあるとステートとして認識されませんでした。
以下のように引数をなくすと、正常に動作しました。

  const handleEmpty = () => {
    setTodos(() => todos.filter((todo) => !todo.removed));
  };
はっぱはっぱ

コメントありがとうございます。

始めのtodosがあるとステートとして認識されませんでした。

「始めの」は更新関数の引数のことだと思いますが妙ですね。
私も同じ環境(↓)でやってみましたが問題なく動いています。原因は分かりません。

package.json
{
  "name": "vite-project",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "localforage": "^1.10.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@eslint/js": "^9.13.0",
    "@types/react": "^18.3.12",
    "@types/react-dom": "^18.3.1",
    "@vitejs/plugin-react": "^4.3.3",
    "eslint": "^9.13.0",
    "eslint-plugin-react-hooks": "^5.0.0",
    "eslint-plugin-react-refresh": "^0.4.14",
    "globals": "^15.11.0",
    "typescript": "~5.6.2",
    "typescript-eslint": "^8.11.0",
    "vite": "^5.4.10"
  }
}
kota0716kota0716

早速返信を下さりありがとうございました。package.jsonを比較してみたのですが、特に違いはありませんでした。