LoginSignup
9
4

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-01

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

 作るもの

ezgif.com-gif-maker (1).gif

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

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

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

 環境構築

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

terminal
npx create-next-app --ts --use-npm your-project

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

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

terminal
cd your-project

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

terminal
npm run dev

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

スクリーンショット 2021-11-29 13.49.35.png

Tailwind CSS の導入

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

基本的な入力フォームの作成

タスクアプリを実装していきます。
まず、pages/index.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

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

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

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

index.tsx
//フォームに入力された値をtodoに登録するまで保持しておくためのstate
const [text, setText] = useState('');
  • useState:
    • 引数となるのはステイトの初期値
    • 現在のステイトtextとそれを更新するための関数setTextを返す。
  • text: 現在のステイトの値
  • setText: ステイトを更新するメソッド
    • 'set' + ステイト(ロワーキャメルケース)で書きます。
  • useStateの引数:ステイトの初期値(=空の文字列)
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

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

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

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

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

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

const App = () => {

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

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

  return (

todosステイトを更新する

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

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()のみだと即時に実行されるためです。

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>タグへ展開します。

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

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

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

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)を付加します。

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

keyにindex番号を割り当てる時(非推奨)

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

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

スクリーンショット 2021-11-29 17.14.35.png

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

削除機能を追加する

追加したタスクを削除できるようにします。
Booleantruefalseで削除/未削除を管理します。Todo 型に追加します。

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

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

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

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

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

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

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

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

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

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 型を作ります。

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

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

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

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

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 型配列の要素をフィルタリングします。
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>タグにフィルタリング済みのリストを渡すように書き換えます。

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

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

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}
        />

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

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

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

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'という状態が発生しないので入力フォームからこれを削除します。

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フラグが立っている要素を取り除きます。

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

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

コード全文
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;

9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4