ReactでTODOアプリを自作しよう|useState×配列操作で追加・削除・完了機能を実装

自作アプリ

Reactの学習を進めていくと、「そろそろ何か作ってみたい」と思うタイミングが来ます。そんなときの最初の制作物としておすすめなのがTODOアプリです。

TODOアプリは、Reactの基本的な機能を一通り使う必要があるため、学んだ知識を実践に落とし込む良い練習になります。

この記事では、ReactのuseStateを使ったシンプルなTODOアプリの作り方を解説します。

対象読者

  • Reactの基礎(JSX、コンポーネント、Props、State)を学習済みの方
  • フォーム操作の基本を理解している方
  • 初めてReactで何か作ってみたい方

TODOアプリに必要な3つの操作

TODOアプリは、突き詰めると以下の3つの操作で成り立っています。

  1. 追加する — 配列に新しいタスクを足す
  2. 削除する — 配列からタスクを取り除く
  3. 状態を変える — タスクの完了/未完了を切り替える

これらはすべて、useStateと配列メソッド(mapfilter)で実現できます。


完成コード

todo.jsx

import { useState } from "react";
import "./todo.css";

export default function StateTodo() {
  // Todo項目idの最大値を管理するためのstate
  const [maxId, setMaxId] = useState(1);
  // Todo項目のリストを管理するためのstate
  const [title, setTitle] = useState("");
  const [todos, setTodos] = useState([]);

  // テキストボックスへの入力値をstateに反映
  const handleChangeTitle = (e) => {
    setTitle(e.target.value);
  };

  // Todoを追加する関数
  const handleClickAdd = () => {
    // 入力値が空の場合は追加しない
    if (title === "") {
      alert("やることを入力してください");
      return;
    }
    // 新しいTodo項目を追加
    setTodos([
      ...todos,
      {
        id: maxId,
        title: title,
        created: new Date(),
        isDone: false,
      },
    ]);
    // Todo項目idの最大値を更新
    setMaxId((id) => id + 1);
    // テキストボックスを空にする
    setTitle("");
  };

  // Todo項目の済み/未済みを切り替える関数
  const handleDone = (e) => {
    setTodos(
      todos.map((item) => {
        if (item.id === Number(e.target.dataset.id)) {
          return {
            ...item,
            isDone: !item.isDone,
          };
        } else {
          return item;
        }
      })
    );
  };

  // Todoを削除する関数
  const handleRemove = (e) => {
    setTodos(todos.filter((item) => item.id !== Number(e.target.dataset.id)));
  };

  // 表示をする
  return (
    <div>
      <label>
        やること:
        <input
          type="text"
          name="title"
          value={title}
          className="title-input"
          onChange={handleChangeTitle}
        />
      </label>
      <button type="button" className="add-button" onClick={handleClickAdd}>
        追加
      </button>
      <hr />
      <ul>
        {todos.map((item) => (
          <li key={item.id} className={`todo ${item.isDone ? "done" : ""}`}>
            {item.title}
            <button
              className={`todo-button ${item.isDone ? "done-button" : "not-done-button"}`}
              type="button"
              onClick={handleDone}
              data-id={item.id}
            >
              {item.isDone ? "完了" : "未完了"}
            </button>
            <button
              className="remove-button"
              type="button"
              onClick={handleRemove}
              data-id={item.id}
            >
              削除
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

todo.css

.done {
  text-decoration: line-through;
}

.add-button {
  margin-left: 10px;
  height: auto;
}

.title-input {
  width: 200px;
  height: 30px;
}

.todo {
  list-style: none;
  border: 1px solid black;
  border-radius: 10px;
  padding: 10px;
  margin: 10px;
  background-color: #f0f0f0;
}

.done-button {
  background-color: lightgreen;
}

.not-done-button {
  background-color: khaki;
}

.todo-button {
  display: inline-block;
  margin-left: 10px;
}

.remove-button {
  display: inline-block;
  margin-left: 10px;
  background-color: lightcoral;
}


コード解説

Stateの設計

const [maxId, setMaxId] = useState(1);
const [title, setTitle] = useState("");
const [todos, setTodos] = useState([]);

このアプリでは3つのStateを使います。

  • maxId: 各タスクに一意のIDを振るためのカウンター。タスクが追加されるたびに1ずつ増えます。
  • title: 入力フォームの値。ユーザーが入力した文字をリアルタイムで保持します。
  • todos: タスクの配列。各タスクは { id, title, created, isDone } というオブジェクトの形を持ちます。

タスクの追加 — スプレッド構文と早期リターン

const handleClickAdd = () => {
  if (title === "") {
    alert("やることを入力してください");
    return;
  }
  setTodos([
    ...todos,
    {
      id: maxId,
      title: title,
      created: new Date(),
      isDone: false,
    },
  ]);
  setMaxId((id) => id + 1);
  setTitle("");
};

ポイントは2つあります。

早期リターン(early return): 関数の先頭でtitleが空かどうかをチェックし、空ならalertを出してreturnで処理を終了します。こうすることでelseを書く必要がなくなり、コードの見通しが良くなります。

スプレッド構文(...todos: Reactでは、Stateの配列を直接変更(ミューテーション)してはいけません。...todosで既存の配列を展開し、末尾に新しいタスクを追加した新しい配列を作成しています。

追加後はsetTitle("")で入力欄を空にしています。これにより、ユーザーは続けて次のタスクを入力できます。

完了/未完了の切り替え — mapと論理否定

const handleDone = (e) => {
  setTodos(
    todos.map((item) => {
      if (item.id === Number(e.target.dataset.id)) {
        return {
          ...item,
          isDone: !item.isDone,
        };
      } else {
        return item;
      }
    })
  );
};

mapメソッドで配列の全要素をループし、クリックされたボタンのdata-idと一致するタスクだけisDoneを反転させます。!item.isDoneとすることで、trueならfalseに、falseならtrueに切り替わります。

一致しないタスクはそのまま返すことで、他のタスクに影響を与えません。

タスクの削除 — filter

const handleRemove = (e) => {
  setTodos(todos.filter((item) => item.id !== Number(e.target.dataset.id)));
};

filterメソッドは、条件に一致する要素だけを残した新しい配列を返します。ここでは「クリックされたIDと一致しないもの」だけを残すことで、該当のタスクを削除しています。

JSXでの条件分岐 — テンプレートリテラルと三項演算子

<li key={item.id} className={`todo ${item.isDone ? "done" : ""}`}>

テンプレートリテラル(` `)を使って、複数のCSSクラスを組み合わせています。todoクラスは常に適用され、doneクラスは完了時のみ追加されます。

この書き方のメリットは、CSSの重複を防げることです。カードの見た目(borderborder-radiuspaddingなど)は.todoに一度だけ書き、完了時の装飾(text-decoration: line-through)は.doneに分離しています。

ボタンのテキストにも同じパターンを使っています。

{item.isDone ? "完了" : "未完了"}

Reactでは、JSXの{}の中でif文を直接書くことができないため、シンプルな二択の場合は三項演算子がよく使われます。


まとめ

TODOアプリは「追加」「削除」「状態の切り替え」という3つの操作で構成されており、それぞれuseStatemapfilterというReactとJavaScriptの基本機能で実現できます。

この記事で扱った主なテクニックは以下のとおりです。

  • useStateによる状態管理
  • スプレッド構文による配列の非破壊的な更新
  • mapによるリスト表示と要素の更新
  • filterによる要素の削除
  • 早期リターンによるバリデーション
  • テンプレートリテラルと三項演算子によるクラスの動的切り替え

ここから先は、Flexboxを使ったレイアウトの改善、登録日時の表示、ローカルストレージへの保存など、さまざまな拡張が考えられます。まずは動くものを作り、そこから少しずつ機能を追加していくのが、React学習の良いサイクルです。

コメント

タイトルとURLをコピーしました