ASP.NET Core + React + TypeScriptでTodoアプリを作成する③(フロントエンド開発編)

ASP.NET Core

バックエンドにASP.NET Core Web API、フロントエンドにReact+TypeScriptを使ったTodoアプリを作成する方法をチュートリアル形式で解説します。

第1回では環境構築、第2回ではバックエンドのAPI作成について解説しました。

第3回ではReactとTypeScriptを使って画面を作成していきます。

スポンサーリンク

環境

  • Windows 10 (Mac OSでも動作確認済み)
  • Visual Studio Code 1.72
  • .NET 6.0 SDK (6.0.401)
  • Node.js 16.17.1
  • React 18.2.0
  • TypeScript 4.8.4

完成イメージ

とてもシンプルですが、最低限のCRUD機能を備えたTodoアプリを作成します。

基本構造の作成とコンポーネントの表示

まずはTodoアプリの基本的な構造を作成します。

src/componentsフォルダ内に「Todo.tsx」というファイルを追加してください。

その中に以下のコードを記述します。

// Todoコンポーネント
export const Todo = () => {
  return (
    <div>
      <h1>Todoリスト</h1>
      <input type="text" />
      <button>追加</button>
      <ul>
          <li>
            <input type="checkbox" />
            <span>プログラミング</span>
            <button>削除</button>
          </li>
          <li>
            <input type="checkbox" />
            <span>ランニング</span>
            <button>削除</button>
          </li>
      </ul>
    </div>
  );
};

一番上にTodoアイテムを追加するためのテキストボックスと追加ボタンを設置し、その下に各Todoアイテムが表示されるようにします。

各Todoアイテムには完了/未完了を表すチェックボックスと、アイテムを削除するためのボタンも用意しました。

次に、index.tsxを書き換えて、Todoコンポーネントを読み込んで表示するようにしてみましょう。

import { Todo } from "./components/Todo";
import { createRoot } from "react-dom/client";

const rootElement = document.getElementById("root")!;
const root = createRoot(rootElement);

root.render(<Todo />);

デバッグ実行するとこのように表示されます。
今はボタンを押しても何も起きませんが、とりあえず最低限の見た目ができました。

オブジェクト配列の動的表示

次はTodoアイテムのオブジェクト配列を動的に表示する仕組みを作ってみます。

Todo.tsxに記述を追加します。

import { useState } from "react";

// TodoItemの型宣言
type TodoItem = {
  id?: number;
  name: string;
  isComplete: boolean;
};

// 初期値
const initialValues = [
  {
    id: 1,
    name: "プログラミング",
    isComplete: false,
  },
  {
    id: 2,
    name: "ランニング",
    isComplete: true,
  },
];

// Todoコンポーネント
export const Todo = () => {
  // Todoアイテムオブジェクトの配列を管理するstate
  const [todos, setTodos] = useState<TodoItem[]>(initialValues);

  return (
    <div>
      <h1>Todoリスト</h1>
      <input type="text" />
      <button>追加</button>
      <ul>
        {/* todoアイテムの配列を展開 */}
        {todos.map((todo) => (
          <li key={todo.id}>
            <input type="checkbox" />
            {/* 完了フラグがtrueの場合は取り消し線を表示 */}
            {todo.isComplete ? (
              <span style={{ textDecorationLine: "line-through" }}>
                {todo.name}
              </span>
            ) : (
              <span>{todo.name}</span>
            )}
            <button>削除</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

追加したコードを上から簡単に説明します。

// TodoItemの型宣言
type TodoItem = {
  id?: number;
  name: string;
  isComplete: boolean;
};

まずTodoアイテムの型をTypeScriptの型エイリアスを使って宣言しています。
中身はバックエンド(C#)で定義したTodoItemモデルと基本的に同じです。

// 初期値
const initialValues = [
  {
    id: 1,
    name: "プログラミング",
    isComplete: false,
  },
  {
    id: 2,
    name: "ランニング",
    isComplete: true,
  },
];

// Todoコンポーネント
export const Todo = () => {
  // Todoアイテムオブジェクトの配列を管理するstate
  const [todos, setTodos] = useState<TodoItem[]>(initialValues);

initialValuesにはTodoアイテムの配列に設定する初期値を設定しています。

コンポーネント内では、useStateを使用してTodoアイテムの配列を状態として管理します。
型には先ほど定義したTodoItem型の配列を指定し、初期値としてinitialValuesを渡します。

      <ul>
        {/* todoアイテムの配列を展開 */}
        {todos.map((todo) => (
          <li key={todo.id}>
            <input type="checkbox" />
            {/* 完了フラグがtrueの場合は取り消し線を表示 */}
            {todo.isComplete ? (
              <span style={{ textDecorationLine: "line-through" }}>
                {todo.name}
              </span>
            ) : (
              <span>{todo.name}</span>
            )}
            <button>削除</button>
          </li>
        ))}
      </ul>

return文の中では、todosの中身(今はinitialValuesが格納されている)をmap関数で取り出して1件ずつ表示します。

その際keyを指定しないと警告が表示されるので、todoアイテムのidを一意のkeyとして指定しています。

また、三項演算子を使って完了フラグがtrueの場合はTodoアイテム名に取り消し線を表示するようにしました。

バックエンドAPIからTodoデータを取得

では第2回で作成したAPIからTodoデータを取得して、画面に表示するように変更してみたいと思います。

今回はシンプルにリクエストを送信できる「axios」というライブラリを使用します。

以下のコマンドを実行してaxiosをインストールしてください。

cd ClientApp
npm install axios

Todo.tsxを書き換えます。

import axios from "axios";
import { useEffect, useState } from "react";

// TodoItemの型宣言
type TodoItem = {
  id?: number;
  name: string;
  isComplete: boolean;
};

// Todoコンポーネント
export const Todo = () => {
  // Todoアイテムオブジェクトの配列を管理するstate
  const [todos, setTodos] = useState<TodoItem[]>([]);

  // ページ初期表示時の処理
  useEffect(() => {
    // APIからTodoデータを取得する関数を定義
    const fetchTodoData = async () => {
      try {
        // APIにGETリクエストし、レスポンスからTodoアイテムオブジェクトの配列を取り出す
        const { data } = await axios.get("api/todoitems");

        // stateにセット
        setTodos(data);
      } catch (e) {
        console.error(e);
      }
    }
    // 関数の実行
    fetchTodoData();
  }, []);

  return (
    <div>
      <h1>Todoリスト</h1>
      <input type="text" />
      <button>追加</button>
      <ul>
        {/* todoアイテムの配列を展開 */}
        {todos.map((todo) => (
          <li key={todo.id}>
            <input type="checkbox" />
            {/* 完了フラグがtrueの場合は取り消し線を表示 */}
            {todo.isComplete ? (
              <span style={{ textDecorationLine: "line-through" }}>
                {todo.name}
              </span>
            ) : (
              <span>{todo.name}</span>
            )}
            <button>削除</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

※今後「todos」の初期値はAPIから取得したTodoデータになるので、useStateの初期値を空配列にし、initialValuesは削除しています。

  // ページ初期表示時の処理
  useEffect(() => {
    // APIからTodoデータを取得する関数を定義
    const fetchTodoData = async () => {
      try {
        // APIにGETリクエストし、レスポンスからTodoアイテムオブジェクトの配列を取り出す
        const { data } = await axios.get("api/todoitems");

        // stateにセット
        setTodos(data);
      } catch (e) {
        console.error(e);
      }
    }
    // 関数の実行
    fetchTodoData();
  }, []);

ページ表示時にAPIからTodoデータを取得するようにしたいので、データ取得処理をuseEffect内に書きました。

まずTodoItemsControllerのGetTodoItemsメソッドにGETリクエストを送信し、レスポンスが取得できたらその中からTodoItemデータを取り出して、todosに格納します。

デバッグ実行すると、第2回で登録したデータが正しく画面に表示されるはずです。

もしうまくデータが取得できない場合は、setProxy.jsの設定(第1回で解説)が正しいかどうかや、DBにデータが登録されているかどうか(第2回で解説)を確認してみてください。

Todoアイテムの登録・更新・削除機能の追加

GET以外の処理も追加してみたいと思います。変更箇所が多いので、下で詳しく解説します。

import { useState, useEffect, ChangeEvent } from "react";
import axios from "axios";

// TodoItemの型宣言
type TodoItem = {
  id?: number;
  name: string;
  isComplete: boolean;
};

// Todoコンポーネント
export const Todo = () => {
  // Todoアイテムオブジェクトの配列を管理するstate
  const [todos, setTodos] = useState<TodoItem[]>([]);

  // テキストボックスの文字列を管理するstate
  const [text, setText] = useState("");

 // テキストボックス入力時の処理
  const handleChangeInput = (e: ChangeEvent<HTMLInputElement>) => {
    // テキストボックスの文字列をstateにセット
    setText(e.target.value);
  };

  // 追加ボタンクリック時の処理
  const handleAdd = async () => {
    // 新しいTodoアイテムのオブジェクトを作成(idはDB側で自動採番するため省略)
    const newTodo = { name: text, isComplete: false };

    try {
      // APIにPOSTリクエストし、レスポンスから登録したTodoアイテムオブジェクトを取り出す
      const { data } = await axios.post("api/todoitems", newTodo);

      // 既存のTodoアイテムと新規登録したTodoアイテムを合体させてstateにセット
      setTodos([...todos, data]);
    } catch (e) {
      console.error(e);
    }
    // テキストボックスをクリア
    setText("");
  };

  // 完了ステータス(チェックボックス)変更時の処理
  const handleChangeStatus = async (id?: number) => {
    // 対象のTodoアイテムの完了フラグを反転して新しい配列に格納
    const newTodos = todos.map((todo) => {
      if (todo.id === id) {
        todo.isComplete = !todo.isComplete;
      }
      return todo;
    });

    // 更新対象のTodoアイテムを取得
    const targetTodo = newTodos.filter((todo) => todo.id === id)[0];

    try {
      // APIに更新対象のTodoアイテムをPUTリクエスト
      await axios.put(`api/todoitems/${id}`, targetTodo);

      // 新しい配列をstateにセット
      setTodos(newTodos);
    } catch (e) {
      console.error(e);
    }
  };

  // 削除ボタンクリック時の処理
  const handleDelete = async (id?: number) => {
    try {
      // APIに削除対象のidをDELETEリクエスト
      await axios.delete(`api/todoitems/${id}`);

      // 削除対象以外のTodoアイテムを抽出してstateにセット
      setTodos(todos.filter((todo) => todo.id !== id));
    } catch (e) {
      console.error(e);
    }
  };

  // ページ初期表示時の処理
  useEffect(() => {
    // APIからTodoデータを取得する関数を定義
    const fetchTodoData = async () => {
      try {
        // APIにGETリクエストし、レスポンスからTodoアイテムオブジェクトの配列を取り出す
        const { data } = await axios.get("api/todoitems");

        // stateにセット
        setTodos(data);
      } catch (e) {
        console.error(e);
      }
    }
    // 関数の実行
    fetchTodoData();
  }, []);

  return (
    <div>
      <h1>Todoリスト</h1>
      <input type="text" onChange={handleChangeInput} value={text} />
      <button onClick={handleAdd}>追加</button>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.isComplete}
              onChange={() => {
                handleChangeStatus(todo.id);
              }}
            />
            {todo.isComplete ? (
              <span style={{ textDecorationLine: "line-through" }}>
                {todo.name}
              </span>
            ) : (
              <span>{todo.name}</span>
            )}
            <button onClick={() => handleDelete(todo.id)}>削除</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

まずテキストボックスを入力した時と追加ボタンをクリックした時のイベントを見てみます。

// テキストボックスの文字列を管理するstate
  const [text, setText] = useState("");

  // テキストボックス入力時の処理
  const handleChangeInput = (e: ChangeEvent<HTMLInputElement>) => {
    // テキストボックスの文字列をstateにセット
    setText(e.target.value);
  };

  // 追加ボタンクリック時の処理
  const handleAdd = async () => {
    // 新しいTodoアイテムのオブジェクトを作成(idはDB側で自動採番するため省略)
    const newTodo = { name: text, isComplete: false };

    try {
      // APIにPOSTリクエストし、レスポンスから登録したTodoアイテムオブジェクトを取り出す
      const { data } = await axios.post("api/todoitems", newTodo);

      // 既存のTodoアイテムと新規登録したTodoアイテムを合体させてstateにセット
      setTodos([...todos, data]);
    } catch (e) {
      console.error(e);
    }
    // テキストボックスをクリア
    setText("");
  };

テキストボックスに文字を入力すると、新たに定義したsetTextによってtext変数に値が格納されます。

追加ボタンをクリックすると、テキストボックスに入力されている値を元に新たなオブジェクトを作成し、TodoItemsControllerのPostTodoItemメソッドにPOSTリクエストを送信します。

バックエンド側の処理が成功したら、GETと同様にレスポンスの中身をtodosに格納します。

 // 完了ステータス(チェックボックス)変更時の処理
  const handleChangeStatus = async (id?: number) => {
    // 対象のTodoアイテムの完了フラグを反転して新しい配列に格納
    const newTodos = todos.map((todo) => {
      if (todo.id === id) {
        todo.isComplete = !todo.isComplete;
      }
      return todo;
    });

    // 更新対象のTodoアイテムを取得
    const targetTodo = newTodos.filter((todo) => todo.id === id)[0];

    try {
      // APIに更新対象のTodoアイテムをPUTリクエスト
      await axios.put(`api/todoitems/${id}`, targetTodo);

      // 新しい配列をstateにセット
      setTodos(newTodos);
    } catch (e) {
      console.error(e);
    }
  };

  // 削除ボタンクリック時の処理
  const handleDelete = async (id?: number) => {
    try {
      // APIに削除対象のidをDELETEリクエスト
      await axios.delete(`api/todoitems/${id}`);

      // 削除対象以外のTodoアイテムを抽出してstateにセット
      setTodos(todos.filter((todo) => todo.id !== id));
    } catch (e) {
      console.error(e);
    }
  };

完了チェックボックスのチェックを変更した場合は、対象のTodoアイテムの完了フラグを反転した後に、変更対象のTodoアイテムをidを元に特定しオブジェクトを取得します。

そのオブジェクトをTodoItemsControllerのPutTodoItemメソッドにPUTリクエストし、処理が成功したらtodosに格納します。

削除ボタンクリック時も基本的に同様の流れで、削除が成功したらidが一致するTodoアイテムを除外したものをtodosに格納しています。

 return (
    <div>
      <h1>Todoリスト</h1>
      <input type="text" onChange={handleChangeInput} value={text} />
      <button onClick={handleAdd}>追加</button>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.isComplete}
              onChange={() => {
                handleChangeStatus(todo.id);
              }}
            />

            <button onClick={() => handleDelete(todo.id)}>削除</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

return内では、上で定義した各イベントハンドラとuseStateの変数を設定しています。

デバッグ実行し、Todoアイテムの追加や削除、完了フラグの更新が問題なく動作することが確認できると思います。

これで基本的な機能は全て実装できました。

コンポーネントの分割

今までは1つのコンポーネントの全てを記述していましたが、リファクタリングとして以下の3つのコンポーネントに分割してみたいと思います。

  • TodoInput.tsx:テキストボックスと追加ボタンに関するコンポーネント
  • TodoList.tsx:各Todoアイテムのリスト表示に関するコンポーネント
  • Todo.tsx:上記以外(タイトル表示部分、Typeの定義、各イベントハンドラの定義など)

まずTodoInput.tsxを作成します。

import { ChangeEvent } from "react";

type Props = {
  text: string;
  onChange: (e: ChangeEvent<HTMLInputElement>) => void;
  onClick: () => Promise<void>;
};

export const TodoInput = ({ text, onChange, onClick }: Props) => {
  return (
    <>
      <input type="text" onChange={onChange} value={text} />
      <button onClick={onClick}>追加</button>
    </>
  );
};

親のTodo.tsxからpropsとして入力テキスト、onChangeイベント、onClickイベントを受け取り、それらを適切な箇所に設定します。

propsのonChangeやonClickの型定義は、メソッドの名前をホバーした際に表示される型をそのままコピペしています。

import { TodoItem } from "./Todo";

type Props = {
  todos: TodoItem[];
  onChange: (id?: number) => Promise<void>;
  onClick: (id?: number) => Promise<void>;
};

export const TodoList = ({ todos, onChange, onClick }: Props) => {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.isComplete}
            onChange={() => {
              onChange(todo.id);
            }}
          />
          {todo.isComplete ? (
            <span style={{ textDecorationLine: "line-through" }}>
              {todo.name}
            </span>
          ) : (
            <span>{todo.name}</span>
          )}
          <button onClick={() => onClick(todo.id)}>削除</button>
        </li>
      ))}
    </ul>
  );
};

Todoアイテムを表示する部分のコンポーネントです。

こちらもTodoInput.tsxと同様にpropsの型を定義し、親コンポーネントからpropsを受け取ります。
(TodoItem型はまだexportしていないのでエラーになりますが無視して大丈夫です。)

最後にTodo.tsxです。元のファイルから変更があった部分のみ記載します。

import { useState, useEffect, ChangeEvent } from "react";
import axios from "axios";
import { TodoList } from "./TodoList";
import { TodoInput } from "./TodoInput";

export type TodoItem = {
  id?: number;
  name: string;
  isComplete: boolean;
};

<省略>

  return (
    <div>
      <h1>Todoリスト</h1>
      <TodoInput text={text} onChange={handleChangeInput} onClick={handleAdd} />
      <TodoList
        todos={todos}
        onChange={handleChangeStatus}
        onClick={handleDelete}
      />
    </div>
  );
};

最初に作成したコンポーネントの読み込みと、子コンポーネントで利用できるようにTodoItem型のexportを行っています。

TodoInputとTodoListコンポーネントを利用時には、propsとして必要なメソッド等を渡しています。return文の中身がかなりスッキリしました。

デバッグ実行し、コンポーネント分割前と挙動に変化がないことを確認してください。

MUIによるスタイリング

最後に見た目をきれいしていきたいと思います。

今回はGoogleが提供しているReact向けUIライブラリの「MUI(旧Material-UI)」を使用します。

詳細な使い方までは解説できないので、気になる方は公式ドキュメントを参照してください。

Overview - Material UI
Material UI is a library of React UI components that implements Google's Material Design.

まずMUIの利用に必要なパッケージをインストールします。

npm install @mui/icons-material @mui/material @emotion/react @emotion/styled

TodoInput.tsxを書き換えます。

import { Fab, TextField } from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import { ChangeEvent } from "react";

type Props = {
  text: string;
  onChange: (e: ChangeEvent<HTMLInputElement>) => void;
  onClick: () => Promise<void>;
};

export const TodoInput = ({ text, onChange, onClick }: Props) => {
  return (
    <>
      <TextField
        sx={{ width: "100%", maxWidth: 270, marginRight: 2, marginBottom: 2 }}
        size="small"
        variant="outlined"
        onChange={onChange}
        value={text}
      />
      <Fab size="small" color="primary" onClick={onClick}>
        <AddIcon />
      </Fab>
    </>
  );
};

MUIからFAB(Floating Action Buttonの略。浮き上がって見えるボタン)とTextField(テキストボックス)、AddIcon(「+」のアイコン)を読み込み、input要素とbutton要素を置き換えています。

onChangeイベントやonClickイベント等は従来通り渡すことができます。

プロパティの値を変更することで様々なカスタマイズが可能なので、興味のある方は公式ドキュメントを参照してみてください。

import {
  Checkbox,
  Fab,
  List,
  ListItem,
  ListItemButton,
  ListItemText,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import { TodoItem } from "./Todo";

type Props = {
  todos: TodoItem[];
  onChange: (id?: number) => Promise<void>;
  onClick: (id?: number) => Promise<void>;
};

export const TodoList = ({ todos, onChange, onClick }: Props) => {
  return (
    <List sx={{ width: "100%", maxWidth: 300, marginInline: "auto" }}>
      {todos.map((todo) => {
        return (
          <ListItem key={todo.id} disablePadding>
            <ListItemButton>
              <Checkbox
                checked={todo.isComplete}
                onChange={() => {
                  onChange(todo.id);
                }}
              />
              <ListItemText>
                {todo.isComplete ? (
                  <span style={{ textDecorationLine: "line-through" }}>
                    {todo.name}
                  </span>
                ) : (
                  <span>{todo.name}</span>
                )}
              </ListItemText>
              <Fab size="small" color="error" onClick={() => onClick(todo.id)}>
                <DeleteIcon />
              </Fab>
            </ListItemButton>
          </ListItem>
        );
      })}
    </List>
  );
};

ここではMUIからCheckboxList、DeleteIcon(ゴミ箱のアイコン)などをインストールし、input要素やul・li要素を置き換えています。

こちらも公式ドキュメントを参考に、お好みの設定にカスタマイズしてみてください。

<省略>

  return (
    <div style={{ textAlign: "center" }}>
      <h1>Todoリスト</h1>

<省略>

最後にTodo.tsxですが、全体を中央寄せにするためにdivにstyleを追加しました。

以上でスタイルの適用は完了です。実行して見た目を確認してみてください。

まとめ

長くなりましたが、ASP.NET Core Web APIとReact+TypeScriptを使用したTodoアプリが作成できました。

シンプルですが、CRUDやReactの基本機能が詰まったになったかと思います。

今後はクラウド上にアプリをデプロイする方法についても解説する予定です。

おすすめ教材

今回はReactやTypeScriptの基礎を解説できませんでしたが、基礎からしっかりと学びたい方はUdemyのこちらの講座がとてもおすすめです。

iconicon【2022年最新】React(v18)完全入門ガイド|Hooks、Next.js、Redux、TypeScripticon

コメント

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