💪

React の学習のためにTodoアプリを作ってみた

2024/02/23に公開

※: この記事はReactの学習過程を記録として残したものです。

はじめに

Reactの勉強については一通りドキュメントや、動画教材で学習を進めたので、実践編として実際に動くものを作ってみようということで、今回はTodoアプリ作成に挑戦していきたいと思います。

完成したアプリとそのソースコードは以下のリンクからアクセスできます。

使用した開発環境はViteで、アプリケーションはReactとTypeScriptを組み合わせ、CSSはvanilla-extractを利用しています。

Todoアプリですが、以下のような機能を実装していこうと思います。

  • 入力欄にタスクを入力をして追加ボタンを押すと、下に一覧が表示される
  • 各タスクには「未着手」「着手中」「完了」というステータスを持たせる
  • ステータスごとに表示するタスクを絞り込める

はじめに作成したいアプリの全体像を描き、各コンポーネントをどのように分割し、階層構造をどうするか計画します。作りながら変わることもあると思うので、まずは手を動かせる程度にざっくりしたものを作ってみました。

1. 開発環境の準備

パッケージマネージャーは pnpm を使います。
npmと比べてディスク容量を効率化でき、またインストールも高速です。

  • 最初に、npmを介してpnpmをインストールします。
npm install -g pnpm
  • 次に、Viteを用いて開発環境を構築します。
pnpm create vite
  • 新しく作成されたディレクトリでpnpmをインストールします。
pnpm install

2. 実装の進め方

  • まずは、Reactを使用して静的なページ(基本的な骨組み)を作成します。この際、Reactの公式ドキュメントに記載されている「Reactの流儀」に従って、stateを使用しない実装から始めます。
  • 静的ページが完成したら、useStateを使用して値を動的に変更できるようにします。

3. コード

工夫した点

  • リロードしてもTodoが保持されるように、Todoリストが更新されるたびにローカルストレージに保存する処理を入れています
  • ステータスの絞り込み条件を保存できるように、useSearchParams を使ってURLにクエリパラメータを持たせています。
App.tsx

// ルーティングの設定
const router = createBrowserRouter([
  {
    path: "/",
    element: <TodoPage />,
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

// ローカルストレージからTodoリストを取得
const storedTodos = JSON.parse(
  localStorage.getItem("todos") || "[]",
) as TodoType[];

function TodoPage() {
  const [todos, setTodos] = useState<TodoType[]>(storedTodos);
  const [searchParams, setSearchParams] = useSearchParams();

  // 新しいtodoを追加する処理
  const addTodoItem = (todoTitle: string): void => {
    const newTodo: TodoType = {
      id: todos.length + 1,
      title: todoTitle,
      status: TODO_STATUS.TODO,
    };
    const newTodos = [...todos, newTodo];
    setTodos(newTodos);
    // Todoリストが更新されるたびにローカルストレージに保存する
    localStorage.setItem("todos", JSON.stringify(newTodos));
  };

  // todoを削除する処理
  const deleteTodoItem = (id: number): void => {
    const deletedTodos = todos.filter((todo) => todo.id !== id);
    setTodos(deletedTodos);
    localStorage.setItem("todos", JSON.stringify(deletedTodos));
  };

  // ステータスを変更する処理
  const changeItemStatus = (
    id: number,
    newStatus: TodoType["status"],
  ): void => {
    const updateTodos = todos.map((todo) => {
      // 指定されたIDのTodoを見つけた場合、新しいステータスで更新
      if (todo.id === id) {
        // スプレッド構文を使って新しいオブジェクトを作成し、statusプロパティを更新
        return { ...todo, status: newStatus };
      }
      return todo;
    });
    setTodos(updateTodos); // 更新されたTodoリストで状態をセット
    localStorage.setItem("todos", JSON.stringify(updateTodos));
  };

  // フィルターを適用する処理
  const handleStatusChange = (
    status: TodoType["status"],
    isChecked: boolean,
  ): void => {
    // チェックされたらフィルターに追加 (isChecked が true の場合、statusと一致するものだけのクエリ文字列を作る)
    if (isChecked) {
      // filter= の後の文字列を配列にして、重複を取り除いてから、新しいstatusを追加して、また文字列に戻す
      setSearchParams((prev) => {
        // ?filter=xxx の xxx 部分

        const filterQueryValue = prev.get("filter") || null;

        const filterStatus = filterQueryValue?.split(",") ?? [];

        const filterStatusSet = new Set(filterStatus);

        filterStatusSet.add(status);

        // Set から配列に戻し、カンマ区切りの文字列にする
        // ["hoge", "foo", "bar"].join(",") -> "hoge,foo,bar"
        prev.set(
          "filter",
          Array.from(filterStatusSet).join(","),
        );

        return new URLSearchParams(prev);
      });
    } else {
      // チェックが外されたらフィルターから削除 (isCheckedがfalseの場合、statusと一致しないものだけのクエリ文字列を作る)
      setSearchParams((prev) => {
        const filterQueryValue = prev.get("filter") || null;

        const filterStatus = filterQueryValue?.split(",") ?? [];

        const filterStatusSet = new Set(filterStatus);
     
        filterStatusSet.delete(status);

        prev.set(
          "filter",
          Array.from(filterStatusSet).join(","),
        );

        return new URLSearchParams(prev);
      });
    }
  };
  // フィルターを適用したTodoリストを作成する処理
  // http://xxx.com/?filter=未着手,完了
  const filters = (searchParams.get("filter") || null)?.split(",") ?? [];
  const filteredTodos = todos.filter((todo) => {
    return filters.length === 0 || filters.includes(todo.status);
  });

  return (
    <>
      <div className={container}>
        <h1 className={title}>タスク管理</h1>
        <div className={filterbar}>
          <Filterbar onStatusChange={handleStatusChange} />
          <AddTodo onAddTodo={addTodoItem} />
        </div>
        <div>
          <TodoList
            todos={filteredTodos}
            onDelete={deleteTodoItem}
            onChangeStatus={changeItemStatus}
          />
        </div>
        <div>
          {todos.length > 0
            ? `残タスク数:${todos.length}`
            : "タスクはありません"}
        </div>
      </div>
    </>
  );
}

export default App;
AddTodo.tsx

type AddTodoProps = {
  onAddTodo: (todoTitle: string) => void;
};

export function AddTodo({ onAddTodo }: AddTodoProps) {
  const [newTodo, setNewTodo] = useState("");

  const handleAddChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setNewTodo(e.target.value);
  };

  const handleAddNewTodo = () => {
    // 何も入力されていない場合は何も返さない
    if (newTodo) {
      onAddTodo(newTodo); // 親コンポーネントの addTodoItem 関数を呼び出して新しい Todo を追加
    }
    setNewTodo(""); // 入力欄をクリア
  };

  return (
    <>
      <div>
        <input
          type="text"
          value={newTodo}
          placeholder="タスクを追加"
          onChange={handleAddChange}
          className={inputBar}
        />
        <button onClick={handleAddNewTodo} className={buttonStyle}>
          追加
        </button>
      </div>
    </>
  );
}

Filterbar.tsx
type FilterbarProps = {
  onStatusChange: (status: TodoType["status"], isChecked: boolean) => void;
};

export function Filterbar({ onStatusChange }: FilterbarProps) {
  return (
    <div className={filterItem}>
      {Object.values(TODO_STATUS).map((status) => (
        <div key={status} className={filterCheckboxGroup}>
          <input
            type="checkbox"
            name="status"
            value={status}
            onChange={(e) =>
              onStatusChange(status, e.target.checked)}
            className={checkbox}
          />
          <span>{status}</span>
        </div>
      ))}
    </div>
  );
}

Todo.tsx
type TodoProps = {
  todo: TodoType;
  onDelete: () => void;
  onChangeStatus: (id: number, newStatus: TodoType["status"]) => void;
};

export function TodoList({ todos, onDelete, onChangeStatus }: TodoListProps) {
  return (
    <div>
      {todos.map((todo) => (
        <Todo
          key={todo.id}
          todo={todo}
          onDelete={() => onDelete(todo.id)}
          onChangeStatus={onChangeStatus}
        />
      ))}
    </div>
  );
}

export default TodoList;
TodoList.tsx
type TodoListProps = {
  todos: TodoType[];
  onDelete: (id: number) => void;
  onChangeStatus: (id: number, newStatus: TodoType["status"]) => void;
};
export function TodoList({ todos, onDelete, onChangeStatus }: TodoListProps) {
  return (
    <div>
      {todos.map((todo) => (
        <Todo
          key={todo.id}
          todo={todo}
          onDelete={() => onDelete(todo.id)}
          onChangeStatus={onChangeStatus}
        />
      ))}
    </div>
  );
}

export default TodoList;

最終型はこんな感じ

4. デプロイ

  • 完成したアプリをインターネット上に公開するため、Denoを使用してデプロイします。

まとめ

第一弾 Todo アプリでした。

実際に手を動かしながらプロジェクトを進めることで、自分が理解で来ていないところを潰しながら進められるので学習効率が良いなと感じています。実装中に発生するエラーも検証ツールを使って調べながら進めるので課題解決力も上がると思います。今後もアプリ開発に挑戦していきたいと思います。

Discussion