React Hooks と TypeScript で簡単 TODO アプリ

23 min read読了の目安(約20700字

公式チュートリアル以外のチュートリアルを探している人向け。

https://ja.reactjs.org/tutorial/tutorial.html

完成予想図

下準備

create-react-app で TypeScript 用のひな形を準備する。

bash
$ npx create-react-app todo --template typescript

Need to install the following packages:
  create-react-app
Ok to proceed? (y)

Creating a new React app in C:\Users\zenn\Downloads\todo.

Installing packages. This might take a couple of minutes.
Installing react, react-dom, and react-scripts with cra-template-typescript...

~ snip ~

We suggest that you begin by typing:

  cd todo
  npm start

Happy hacking!

$ cd todo

https://github.com/facebook/create-react-app

https://ja.reactjs.org/docs/create-a-new-react-app.html

手順

簡潔すぎるかもしれない手順とスクリーンショット。

1. 初期状態

Hello. とのみ表示される関数コンポーネント。

src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';

const App: React.VFC = () => {
  return (
    <div>
      <h1>Hello.</h1>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById('root'));

スクリーンショット 2021-04-24 17.40.18.png

create-react-app が自動的にホットリロードしてくれないとき(まれによくある)は、ブラウザのリロードボタンや Ctrl+R を使ってください。

2. Todo を入力するフォームを作成

  • onSubmitonChange のイベントは preventDefault() してしまっているので特に何も起きない
index.tsx
const App: React.VFC = () => {
  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>
  );
};

スクリーンショット 2021-04-24 17.41.16.png

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

  • useState フック現在の state の値と、それを更新するための関数とをペアにした配列を返す
  • useState の引数はそのステートの初期値
src/index.tsx
// useState をインポート
import React, { useState } from 'react';
import ReactDOM from 'react-dom';

const App: React.VFC = () => {
  /**
   * text = ステートの値
   * setText = ステートの値を更新するメソッド
   */
  const [text, setText] = useState('');

  return (
    <div>
      <form onSubmit={(e) => e.preventDefault()}>
        {/* 
          入力中テキストの値を text ステートが
          持っているのでそれを value として表示

          onChange イベント(=入力テキストの変化)を
          text ステートに反映する
         */}
         <input
           type="text"
           value={text}
           onChange={(e) => setText(e.target.value)}
         />
          <input type="submit" value="追加" onSubmit={(e) => e.preventDefault()} />
        </form>
      </div>
    );
  };

スクリーンショット 2021-04-24 17.54.47 (1).png

4. Todo の仕様を考える(その1)

タスクの内容として string 型の value というプロパティを持つ。

index.tsx
interface Todo {
  value: string;
}

const App: React.VFC = () => {

タスクたち(todos 複数)は Todo 型オブジェクトの配列 とする。

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

5. onSubmit() イベントで text ステート の内容を todos ステート配列に追加する

  • 配列のステートをそのまま触ってはいけない
  • コピーに対して変更を加えてから更新すること

チュートリアル:React の導入 - イミュータビリティは何故重要なのか(公式)

https://qiita.com/sh-suzuki0301/items/597bdbf17253feb5f55b

6. todos ステートを更新するコールバック関数を作成する

いったん e.preventDefault() しているのは Enter キー打鍵でページそのものがリロードされてしまうのを防ぐため。

src/index.tsx
  const [todos, setTodos] = useState<Todo[]>([]);

  // todos ステートを更新する関数
  const handleOnSubmit = (
    e: React.FormEvent<HTMLFormElement | HTMLInputElement>
  ) => {
    e.preventDefault();

    // 何も入力されていなかったらリターン
    if (!text) return;

    // 新しい Todo を作成
    const newTodo: Todo = {
      value: text,
    };

    /**
     * スプレッド構文を用いて todos ステートのコピーへ newTodo を追加する
     *
     * 以下と同義
     * const oldTodos = todos.slice();
     * setTodos(oldTodos.splice(0, 0, newTodo));
     *
     **/
    setTodos([newTodo, ...todos]);
    // フォームへの入力をクリアする
    setText('');
  };

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

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

スクリーンショット 2021-04-25 7.19.16.png

7. コールバック関数をイベントに割り当てる

  • コールバックとして渡すのは関数そのもの () => hoge()
  • hoge() のみだと即時に実行されてしまうので用をなさない

https://sbfl.net/blog/2019/02/08/javascript-callback-func/
src/index.tsx
  return (
    <div>
   // コールバックとして (e: Event) => handleOnSubmit(e) を渡す
     <form onSubmit={(e) => handleOnSubmit(e)}>
        <input
          type="text"
          value={text}
          onChange={(e) => setText(e.target.value)}
        />
	{/* 上に同じ */}
        <input type="submit" value="追加" onSubmit={(e) => handleOnSubmit(e)} />
     </form>
   </div>

フォームへ入力して submit すると ステート (todos) が更新されていることが確認できる。

スクリーンショット 2021-04-25 7.33.48 (1).png

8. ステート todos を展開してページに表示する

todos (=配列) を非破壊メソッドである Array.prototype.map() を使って <li></li> タグへ展開する。

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

https://zenn.dev/kojinishimura/articles/78450a1fe35664
src/index.tsx
    <div>
      <form onSubmit={(e) => handleOnSubmit(e)}>
        <input
          type="text"
          value={text}
          onChange={(e) => setText(e.target.value)}
        />
        <button onClick={() => handleOnClick()}>追加</button>
      </form>
      <ul>
        {todos.map((todo) => {
          return <li>{todo.value}</li>;
        })}
      </ul>
    </div>

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

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

スクリーンショット 2021-04-25 7.45.35.png

スクリーンショット 2021-04-25 7.44.18.png

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

それぞれの Todo (タスク)に一意な ID を与える必要があるため、number 型のプロパティを Todo 型に追加する。

src/index.tsx
interface Todo {
  value: string;
  id: number;
}

handleOnSubmit() メソッドを更新。

src/index.tsx
const handleOnSubmit = (
    e: React.FormEvent<HTMLFormElement> | React.FormEvent<HTMLInputElement>
  ) => {
    e.preventDefault();
    if (!text) return;

    const newTodo: Todo = {
      value: text,
      /**
      * Todo の型定義により
      * number 型の id プロパティが必要になった
      */
      id: new Date().getTime(),
    };

    setTodos([newTodo, ...todos]);
    setText('');
  };

<li></li> タグに key (=id) を付加する。

src/index.tsx
      <ul>
        {todos.map((todo) => {
          return <li key={todo.id}>{todo.value}</li>;
        })}
      </ul>

スクリーンショット 2021-04-25 10.01.09.png

https://zenn.dev/luvmini511/articles/f7b22d93e9c182

10. 登録済みの todo を編集可能にする

todo.value を <input /> タグでラップする。

src/index.tsx
      <ul>
        {todos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="text"
                value={todo.value}
                onChange={(e) => e.preventDefault()}
              />
            </li>
          );
        })}
      </ul>

ここでも、とりあえず e.preventDefault() しているので入力しても何も起きない。

スクリーンショット 2021-04-25 10.04.48.png

11. 登録済み todo が編集された時のコールバック関数を作成

  • どの todo が編集されたのか特定するため、その todoid を引数として受け取る
  • e.target.value を書き換え後の todo.value の値とするために第2引数として受け取る
src/index.tsx
  const handleOnEdit = (id: number, value: string) => {
    /**
     * 引数として渡された todo の id が一致する
     * todos ステート(のコピー)内の todo の
     * value プロパティを引数 value に書き換える
     */
    const newTodos = todos.map((todo) => {
      if (todo.id === id) {
        todo.value = value;
      }
      return todo;
    });

    // todos ステートを更新
    setTodos(newTodos);
  };

上のコールバック関数を <input onChange={} /> に割り当てる。

src/index.tsx
      <ul>
        {todos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="text"
                value={todo.value}
                onChange={(e) => handleOnEdit(todo.id, e.target.value)}
               />
            </li>
          );
        })}
      </ul>

スクリーンショット 2021-04-25 10.03.38.png

12. タスクの完了/未完了を操作できるようにする - Todo の仕様を考える(その3)

  • Todo 型に完了/未完了を示すプロパティを追加する
  • 完了/未完了 (= yes or no) を表すので型は Boolean 型
src/index.tsx
interface Todo {
  value: string;
  id: number;
  // 完了/未完了を示すプロパティ
  checked: boolean;
}

handleOnSubmit() メソッドを更新する。

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

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

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

それぞれの todo の前へチェックボックスを置く。

index.tsx
      <ul>
        {todos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="checkbox"
                checked={todo.checked}
                onChange={(e) => e.preventDefault()}
              />
              <input
                type="text"
                value={todo.value}
                onChange={(e) => handleOnEdit(todo.id, e.target.value)}
              />
            </li>
          );
        })}
      </ul>

スクリーンショット 2021-04-25 10.07.23.png

13. チェックボックスがチェックされたときのコールバック関数を作成する

上の handleOnEdit() とパターンは同じ。

src/index.tsx
  const handleOnCheck = (id: number, checked: boolean) => {
    const newTodos = todos.map((todo) => {
      if (todo.id === id) {
        todo.checked = !checked;
      }
      return todo;
    });

    setTodos(newTodos);
  };

チェックボックスのイベントへ紐付ける。

src/index.tsx
 return (
   <li key={todo.id}>
    <input
      type="checkbox"
      checked={todo.checked}
      onChange={() => handleOnCheck(todo.id, todo.checked)}
    />
    <input
      type="text"
      value={todo.value}
      onChange={(e) => handleOnEdit(todo.id, e.target.value)}
    />
   </li>
 );

スクリーンショット 2021-04-25 10.18.15.png

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

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

スクリーンショット 2021-04-25 10.22.45.png

14. 登録済みの todo を削除可能にする

入力フォームの後ろへ削除ボタンを追加する。

index.tsx
  return (
    <li key={todo.id}>
      <input
        type="checkbox"
        checked={todo.checked}
        onChange={() => handleOnCheck(todo.id, todo.checked)}
      />
      <input
        type="text"
        disabled={todo.checked}
        value={todo.value}
        onChange={(e) => handleOnEdit(todo.id, e.target.value)}
      />
      <button onClick={() => console.log('removed!')}>削除</button>
    </li>
  );

スクリーンショット 2021-04-25 10.32.55.png

15. Todo の仕様を考える(その4)

checked の場合と同様に removed というフラグを追加する。

src/index.tsx
interface Todo {
  value: string;
  id: number;
  checked: boolean;
  removed: boolean;
}

handleOnSubmit() メソッドを更新する。

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

    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
      checked: false,
      removed: false,
    };

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

16. 削除ボタンがクリックされたときのコールバック関数を作成する

これも handleOnEdit()handleOnChecked() と同じパターン。

src/index.tsx
  const handleOnRemove = (id: number, removed: boolean) => {
    const newTodos = todos.map((todo) => {
      if (todo.id === id) {
        todo.removed = !removed;
      }
      return todo;
    });

    setTodos(newTodos);
  };

todo.removed のフラグによってボタンのラベルを入れ替える。

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

削除されたアイテムのチェックボックスと入力フォームも無効化する。

src/index.tsx
  <input
    type="checkbox"
    disabled={todo.removed}
    checked={todo.checked}
    onChange={() => handleOnCheck(todo.id, todo.checked)}
  />
  <input
    type="text"
    disabled={todo.checked || todo.removed}
    value={todo.value}
    onChange={(e) => handleOnEdit(todo.id, e.target.value)}
  />

スクリーンショット 2021-04-25 10.51.02.png

17. タスクをフィルタリングする機能を追加する

このままでは削除済みのアイテムもそのまま表示されてしまうので、タスクをフィルタリングする機能を追加する。

フィルタリングするセレクタを作成

ここでも onChange はとりあえずダミー。

src/index.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) => handleOnSubmit(e)}>
~ snip ~
    </div>

18. 現在のフィルターを格納する filter ステートを追加する

filter は4種類とする。

src/index.tsx
type Filter = 'all' | 'checked' | 'unchecked' | 'removed';

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

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

19. セレクタの onChange イベントで filter ステートを更新する

Filter を単なる string 型 にすれば下のようなキャストは不要だが、次項の switch 文で型によるエディタの補完を享受するため、あえて Filter 型 を利用する。

src/index.tsx
      // e.target.value: string を Filter 型にキャストする
      <select
        defaultValue="all"
        onChange={(e) => setFilter(e.target.value as Filter)}>
        <option value="all">すべてのタスク</option>
        <option value="checked">完了したタスク</option>
        <option value="unchecked">未完了のタスク</option>
        <option value="removed">削除済みのタスク</option>
      </select>

20. フィルタリング後の Todo 型の配列を返す関数を用意する

  • <ul></ul> タグの中で展開されている todos ステート をタグへ渡す前に加工する
  • 現在の filter ステート に応じて Todo 型配列 の要素をフィルタリングする
  • Array.prototype.filter() メソッドも非破壊メソッド

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
src/index.tsx
  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;
    }
  });

21. todos ステートを展開する <ul></ul> タグにフィルタリング済みのリストを渡す

src/index.tsx
      <ul>
        {/* todos => filterdTodos 書き換え */}
        {filteredTodos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="checkbox"
                disabled={todo.removed}

「削除済みのタスク」「完了済みのタスク」が表示されている時は、入力フォームは無効化する。

src/index.tsx
      <form onSubmit={(e) => handleOnSubmit(e)}>
        <input
          type="text"
          value={text}
          disabled={filter === 'checked' || filter === 'removed'}
          onChange={(e) => setText(e.target.value)}
        />
        <input
          type="submit"
          disabled={filter === 'checked' || filter === 'removed'}
          value="追加"
          onSubmit={(e) => handleOnSubmit(e)}
        />
      </form>

スクリーンショット 2021-04-25 12.22.46.png

22. 「ゴミ箱を空にする」ボタンを作成する

フィルターで「削除済み」の Todo リストを表示しているときには、削除済みタスクを完全に消去できるようにする。

フィルターが「削除済み」の場合は「ゴミ箱を空にする」するボタンを表示し、それ以外のときは従前の入力フォームを表示する。

src/index.tsx
        <option value="removed">削除済みのタスク</option>
      </select>
      {filter === 'removed' ? (
        <button onClick={() => console.log('remove all')}>
          ゴミ箱を空にする
        </button>
      ) : (
        <form onSubmit={(e) => handleOnSubmit(e)}>
          <input
            type="text"
            value={text}
            disabled={filter === 'checked' || filter === 'removed'}
            onChange={(e) => setText(e.target.value)}
          />
          <input
            type="submit"
            value="追加"
            disabled={filter === 'checked' || filter === 'removed'}
            onSubmit={(e) => handleOnSubmit(e)}
          />
        </form>
      )}
      <ul>
        {filteredTodos.map((todo) => {

こうなると入力フォームが描画される場合には filter === 'removed' という状態が発生し得ないので、入力フォームからこれらを削除する。

2021-04-26 121333.png

src/index.tsx
      {filter === 'removed' ? (
        <button onClick={() => console.log('remove all')}>
          ゴミ箱を空にする
        </button>
      ) : (
        <form onSubmit={(e) => handleOnSubmit(e)}>
          <input
            type="text"
            value={text}
            disabled={filter === 'checked'}
            onChange={(e) => setText(e.target.value)}
          />
          <input
            type="submit"
            value="追加"
            disabled={filter === 'checked'}
            onSubmit={(e) => handleOnSubmit(e)}
          />
        </form>
      )}

fig01.png

23. 「ゴミ箱を空にする」コールバック関数を作成する

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

src/index.tsx
  const handleOnEmpty = () => {
    const newTodos = todos.filter((todo) => !todo.removed);
    setTodos(newTodos);
  }
src/index.tsx
      {filter === 'removed' ? (
        // コールバックに handleOnEmpty() を渡す
        <button onClick={() => handleOnEmpty()}>ゴミ箱を空にする</button>
      ) : (
        <form onSubmit={(e) => handleOnSubmit(e)}>
          <input
            type="text"
            value={text}
            disabled={filter === 'checked'}
            onChange={(e) => setText(e.target.value)}
          />
          <input
            type="submit"
            value="追加"
            disabled={filter === 'checked'}
            onSubmit={(e) => handleOnSubmit(e)}
          />
        </form>
      )}

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

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

fig02.png

さらなる改良のヒント

  • CSS フレームワークなどを利用してルック&フィールを洗練させよう

https://material-ui.com/
  • Web ストレージを利用して Todo リストがリロード後も保持されるようにしよう

https://github.com/localForage/localForage
  • ドラッグ&ドロップで Todo リストの順番を並び替えられるようにしよう

https://github.com/atlassian/react-beautiful-dnd

おすすめのドキュメント

https://ja.reactjs.org/tutorial/tutorial.html

https://ja.reactjs.org/docs/hooks-intro.html

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

おまけ

続編はこちら:

https://zenn.dev/sprout2000/articles/1b52258b507b70