Next.jsとReact Hooksを使って簡易タスクアプリ作ってみた



ReactのフレームワークのNext.jsを使って定番のタスクアプリを作っていきます。

##　作るもの

![ezgif.com-gif-maker (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1881340/5e8d6f9a-76e9-6f0c-3454-0f38fd11178a.gif)


こんな感じの簡単なタスクアプリを作ってみます。

- テキストに入力した値を「追加」ボタンでタスクとして追加できます。
- 「削除」ボタンでタスクを削除できます。
- 「すべてのタスク」、「ゴミ箱」のフィルターでタスクを削除済みかそうでないかに分けています。
- 「ゴミ箱を空にする」ボタンで削除したタスクを完全に消去できます。

こちらの記事を参考にさせていただきました。かなり詳しく書いてありました。

https://zenn.dev/sprout2000/books/76a279bb90c3f3/viewer/chapter01

##　環境構築
Next.jsの環境構築をします。

https://nextjs.org/learn/basics/create-nextjs-app

```zsh:terminal
npx create-next-app --ts --use-npm your-project
```

`create-next-app`でNext.jsのプロジェクトの作成です。
`--ts` でTypeScriptを使用します。
`--use--npm`　で`npm`を優先して使用します。
`your-project`はプロジェクトの名前です。

プロジェクトファイルに移動します。

```zsh:terminal
cd your-project
```

サーバーを立ちあげてみます。

```zsh:terminal
npm run dev
```

ターミナル上の`url: http://localhost:3000`を`command`を押しながらクリックすることで以下のページに飛ぶことができます。

![スクリーンショット 2021-11-29 13.49.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1881340/5dde83f3-e779-7ad2-1b4e-901249716b49.png)

### Tailwind CSS の導入
CSSはTailWindCSSが使いやすいと思います。以下のサイトを参考に導入しました。

https://tailwindcss.com/docs/installation

## 基本的な入力フォームの作成
タスクアプリを実装していきます。
まず、`pages/index.tsx`のデフォルトの内容を全て削除して以下の内容に書き換えます。

```tsx:index.tsx
const App = () => {
  return (
    <div className="w-full mx-auto max-w-2xl my-6 px-3">
      <form onSubmit={(e) => e.preventDefault()}>
        {/* テキスト入力フォーム */}
        <input 
          className="border border-black" 
          type="text" 
          value="" 
          onChange={(e) => e.preventDefault()}
        />
        {/* 追加ボタン */}
        <input
          type="submit"
          value="追加"
          onSubmit={(e) => e.preventDefault()}
        />
      </form>
    </div>
  );
};
export default App;
```

以下のように入力フォームと追加ボタンが表示されます。

![スクリーンショット 2021-11-29 14.55.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1881340/4f0139f0-3a99-beac-cf8a-a753ce1f7dfd.png)

`e.preventDefault()`により`onSubmit`や`onChange`のデフォルトの挙動がキャンセルされています。

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

## フォームに文字を入力できるようにする

フォームに入力された文字列をステイトとして保持するため、`useState`を導入します。

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

```jsx:index.tsx
//フォームに入力された値をtodoに登録するまで保持しておくためのstate
const [text, setText] = useState('');
```

- `useState`:
 - 引数となるのはステイトの初期値
 - 現在のステイト`text`とそれを更新するための関数`setText`を返す。
- `text`: 現在のステイトの値
- `setText`: ステイトを更新するメソッド
  - `'set' + ステイト`（ロワーキャメルケース）で書きます。
- `useState`の引数:ステイトの初期値(=空の文字列)


```diff_jsx:index.tsx
// React から useState をインポート
+ import { useState } from 'react';

const App = () => {
+ const [text, setText] = useState('');

  return (
    <div className="w-full mx-auto max-w-2xl my-6 px-3">
      <form onSubmit={(e) => e.preventDefault()}>
        {/*
          入力中テキストの値を text ステイトが
          持っているのでそれを value として表示
          onChange イベント（＝入力テキストの変化）を
          text ステイトに反映する
         */}
        <input  
          className="border border-black" 
          type="text" 
-         value=""
+         value={text} 
          onChange={(e) => setText(e.target.value)} />
        <input
          type="submit"
          value="追加"
          onSubmit={(e) => e.preventDefault()}
        />
      </form>
    </div>
  );
};
export default App;
```

入力フォームに文字が打てるようになります。
![スクリーンショット 2021-11-29 15.22.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1881340/89a71384-fd4a-b460-524b-2b9326cac192.png)

## todoをリストとして追加できるようにする

### Todoオブジェクトの型の定義

1つの`todo`をオブジェクトとすると、そのオブジェクトにはタスクの内容を保持するプロパティが必要となり、`value`プロパティとして持ちます。
入力フォームに入力されたテキスト文字列が代入されるため、`value` プロパティは`string型`となります。

これから作成される複数の`todo`の雛形として`Todo 型オブジェクト`の型エイリアスを定義します。

```diff_jsx:index.tsx
+ type Todo = {
+   value: string;
+ };

const App = () => {
```

https://typescript-jp.gitbook.io/deep-dive/type-system#eiriasutype-alias

ステイトとして保持するタスクたち(todos)は`Todo 型オブジェクトの配列`となります。

```diff_jsx:index.tsx
const App = () => {
  const [text, setText] = useState('');
+ const [todos, setTodos] = useState<Todo[]>([]);

  return (
```

### todosステイトを更新する
`todos`ステイトを更新（=新しいタスクの追加）していきます。
ステイトを更新するコールバック関数を作成します。

```diff_jsx:index.tsx
const [todos, setTodos] = useState<Todo[]>([]);

  // todos ステイトを更新する関数
+  const handleOnSubmit = () => {
+    // formの内容が空白の場合はalertを出す
+    if (text === "") {
+      alert("文字を入力してください");
+    return;
+    }

    // 新しい Todo を作成
+    const newTodo: Todo = {
+      value: text,
+    };
+    // スプレッド構文を用いて todos ステイトのコピーへ newTodo を追加する
+    setTodos([newTodo, ...todos]);
+    // フォームへの入力をクリアする
+    setText('');
+  };
```


### `onSubmit`イベントに紐付ける
コールバックとして`() => handleOnSubmit()`もしくは`handleOnSubmit`関数そのものを渡します。`handleOnSubmit()`のみだと即時に実行されるためです。

```diff_jsx:index.tsx
 return (
    <div className="w-full mx-auto max-w-2xl my-6 px-3">
      {/*コールバックとして () => handleOnSubmit()を渡す */}
      <form 
        onSubmit={(e) => {
          e.preventDefault();
+         handleOnSubmit();
+       }}
      >
        <input  
          className="border border-black" 
          type="text" 
          value={text} 
          onChange={(e) => setText(e.target.value)} />
        <input
          type="submit"
          value="追加"
+         onSubmit={handleOnsubmit}
        />
      </form>
    </div>
  );
```

`onSubmit`イベントが発火すると`handleOnSubmit`関数が実行され、`todos`ステイトを更新(=新しいタスクを追加)します。

### todosステイトを展開してページに表示する
`todos`ステイトを展開して、タスク一覧としてページに表示します。
`todos (=配列)`を非破壊メソッドの`Array.prototype.map()`を使って`<li></li>`タグへ展開します。

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

Reactではリストをレンダリングする際、どのアイテムが変更になったのか特定する必要があるため、リストの各項目に一意な識別子となる`key`が必要です。
`Todo`型に`id`プロパティとして一意な数字(`number　型`)をもたせることにします。
また、書き換え不可能である`readonly(読み取り専用)`のプロパティとします。

```diff_tsx:index.tsx
type Todo = {
  value: string;
+ readonly id: number;
};
```

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

```diff_jsx:index.tsx
  const handleOnSubmit = () => {
    if (text === "") {
      alert("文字を入力してください");
    return;
    }
    const newTodo: Todo = {
      value: text,
+     id: new Date().getTime(),
    };
    setTodos([newTodo, ...todos]);
    setText("");
  };
```

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

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

#### keyにindex番号を割り当てる時(非推奨)
以下のようにして`key`に`index`番号を割り当てることでも動きますが推奨されていないみたいです。以下のサイトに書いてあります。

```diff_jsx:index.tsx
      <ul>
+       {todos.map((todo, index) => {
+         return <li key={index}>{todo.value}</li>;
        })}
      </ul>
```


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


![スクリーンショット 2021-11-29 17.14.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1881340/84994c7e-ad7f-0ad2-1429-7bd1f50b5754.png)

タスクを追加することができました。

## 削除機能を追加する
追加したタスクを削除できるようにします。
`Boolean`の`true`、`false`で削除/未削除を管理します。`Todo 型`に追加します。

```diff_jsx:index.tsx
type Todo = {
  value: string;
  readonly id: number;
+ removed: boolean;
};
```

`handleOnSubmit()`メソッドを更新します。

```diff_jsx:index.tsx
    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
+     removed: false,
    };
```

それぞれの入力フォームの後ろへ削除ボタンを追加します。
また、すでに削除済みかどうか可視化するため、`todo.removed`の値によってラベルを入れ替えます。

```diff_jsx:index.tsx
          return (
          <li key={todo.id}>
            {todo.value}
+           <button onClick={() => handleOnRemove(todo.id, todo.removed)}>
+             {todo.removed ? "復元" : "削除"}
+           </button>
          </li>
          );
```

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

```diff_jsx:index.tsx
+ const handleOnRemove = (id: number, removed: boolean) => {
+   const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

+   const newTodos = deepCopy.map((todo, todoIndex) => {
+     if (todo.id === id) {
+       todo.removed = !removed;
+     }
+     return todo;
+   });

+   setTodos(newTodos);
+ };
```

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

```diff_jsx:index.tsx
   return (
     <div className="w-full mx-auto max-w-2xl my-6 px-3">
+     <select
+       defaultValue="all"
+       onChange={(e) => setFilter(e.target.value as Filter)}
+     >
+       <option value="all">すべてのタスク</option>
+       <option value="removed">ゴミ箱</option>
+     </select>
```

現在のフィルターを格納する`filter`ステイトを追加します。
フィルターの状態を表す`Filter 型`を作ります。

```diff_jsx:index.tsx
+ type Filter = "all" | "removed";
```

`<option />`タグの値を`Filter 型のステイト`として保持します。

```diff_jsx:index.tsx
const App = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);
+ const [filter, setFilter] = useState<Filter>("all");
```

`onChange`イベントが発火すると、`filterステイト`を更新するようにしています。

```diff_jsx:index.tsx
      // e.target.value: string　を Filter 型にキャストする
      <select
        defaultValue="all"
        onChange={(e) => setFilter(e.target.value as Filter)}
      >
        <option value="all">すべてのタスク</option>
        <option value="removed">ゴミ箱</option>
      </select>
```

フィルタリング後の`Todo 型配列`をリスト表示します。
`todos ステイト`配列の表示方法を変化させる関数を作成します。

- `<ul></ul>`タグの中で展開されている`todos ステイト`をタグへ渡す前に加工します。
- 現在の`filter ステイト`に応じて`Todo 型配列`の要素をフィルタリングします。

```diff_jsx:index.tsx
+ const filteredTodos = todos.filter((todo) => {
+   // filterステイトの値に応じて異なる内容の配列を返す
+   switch (filter) {
+     case "all":
+       // 削除されていないもの全て
+       return !todo.removed;
+     case "removed":
+       // 削除済みのもの
+       return todo.removed;
+     default:
+       return todo;
+   }
+ });
```

`todoステイト`を展開する`<ul></ul>`タグにフィルタリング済みのリストを渡すように書き換えます。

```diff_jsx:index.tsx
      <ul>
-       {todos.map((todo) => {
+       {filteredTodos.map((todo) => {
```

「ゴミ箱」が表示されている時は新しいタスクを追加できないように入力フォームを無効化します。

```diff_jsx:index.tsx
        <input  
          className="border border-black" 
          type="text" 
          value={text} 
+         disabled={filter === "removed"}
          onChange={(e) => handleOnChange(e)} />
        <input
          type="submit"
          value="追加"
+         disabled={filter === "removed"}
          onSubmit={handleOnSubmit}
        />
```

## 「ゴミ箱を空にする」機能を追加する

フィルターで「ゴミ箱」のタスクリストを表示している時は、削除済みタスクを完全に消去できるように機能を追加します。

フィルターが「ゴミ箱」の場合は「ゴミ箱を空にする」ボタンを表示し、それ以外の時は従来の入力フォームを表示するようにします。

```diff_jsx:index.tsx
        <option value="removed">ゴミ箱</option>
      </select>
+     {filter === "removed" ? (
+       <button
+         onClick={handleOnEmpty}
+         disabled={todos.filter((todo) => todo.removed).length === 0}
+       >
+       ゴミ箱を空にする
+       </button>
+     ) : (
      <form 
        onSubmit={(e) => {
          e.preventDefault();
          handleOnSubmit();
        }}
      >
        <input  
          className="border border-black" 
          type="text" 
          value={text} 
          disabled={filter === "removed"}
          onChange={(e) => handleOnChange(e)} />
        <input
          type="submit"
          value="追加"
          disabled={filter === "removed"}
          onSubmit={handleOnSubmit}
        />
      </form>
+     )}
```

三項演算子`? 'trueの時' : 'falseの時'`　で評価しています。

入力フォームが描写される時は`filter === 'removed'`という状態が発生しないので入力フォームからこれを削除します。

```diff_jsx:index.tsx
        <input  
          className="border border-black" 
          type="text" 
          value={text} 
-         disabled={filter === "removed"}
          onChange={(e) => handleOnChange(e)} />
        <input
          type="submit"
          value="追加"
-         disabled={filter === "removed"}
          onSubmit={handleOnSubmit}
        />
```

「ゴミ箱を空にする」コールバック関数の作成します。
`todos ステイト`配列から、`removed`フラグが立っている要素を取り除きます。

```diff_jsx:index.tsx
+  const handleOnEmpty = () => {
+   const newTodos = todos.filter((todo) => !todo.removed);
+   setTodos(newTodos);
+ };
```

以上です。ありがとうございました。コード全文はこちらです。

<details><summary>**コード全文**</summary><div>

```jsx:index.tsx
import { useState } from 'react';

type Todo = {
  value: string;
  readonly id: number;
  removed: boolean;
};

type Filter = "all" | "removed";

const App = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);
  const [filter, setFilter] = useState<Filter>("all");

  // todosステイトを更新する関数
  const handleOnSubmit = () => {
    // 何も入力されていない時
    if (text === "") {
      alert("文字を入力してください");
    return;
    }
    //新しい Todo を作成 
    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
      removed: false,
    };
    setTodos([newTodo, ...todos]);
    //フォームへの入力をクリアする
    setText('');
  };

  // formに入力更新された時更新する関数
  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  // 削除ボタンがクリックされた時更新する関数
  const handleOnRemove = (id: number, removed: boolean) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));
    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.removed = !removed;
      }
      return todo;
    });
    setTodos(newTodos);
  };

  // ゴミ箱を空にする時更新する関数
  const handleOnEmpty = () => {
    const newTodos = todos.filter((todo) => !todo.removed);
    setTodos(newTodos);
  };

  // フィルター
  const filteredTodos = todos.filter((todo) => {
    switch (filter) {
      case "all":
        return !todo.removed;
      case "removed":
        return todo.removed;
      default:
        return todo;
    }
  });

  return (
    <div className="w-full mx-auto max-w-2xl my-6 px-3">
      <select
        defaultValue="all"
        onChange={(e) => setFilter(e.target.value as Filter)}
      >
        <option value="all">すべてのタスク</option>
        <option value="removed">ゴミ箱</option>
      </select>
      {filter === "removed" ? (
        <button
          onClick={handleOnEmpty}
          disabled={todos.filter((todo) => todo.removed).length === 0}
        >
        ゴミ箱を空にする
        </button>
      ) : (
      <form 
        onSubmit={(e) => {
          e.preventDefault();
          handleOnSubmit();
        }}
      >
        <input  
          className="border border-black" 
          type="text" 
          value={text} 
          onChange={(e) => handleOnChange(e)} />
        <input
          type="submit"
          value="追加"
          onSubmit={handleOnSubmit}
        />
      </form>
      )}
      <ul>
        {filteredTodos.map((todo) => {
          return (
          <li key={todo.id}>
            {todo.value}
            <button onClick={() => handleOnRemove(todo.id, todo.removed)}>
              {todo.removed ? "復元" : "削除"}
            </button>
          </li>
          );
        })}
      </ul>
    </div>
  );
};
export default App;
```

</div></details>