※当サイトではアフィリエイト広告を利用しています。

react

reactで作る todo アプリ 3/4

この記事では、図で示したような ToDo アプリの開発手順について全 4 部構成で詳しく解説しています。React の基礎から応用までを実際のコーディングを通じて学ぶことに焦点を当てています。初心者から中級者向けの内容となっており、ToDo アプリの開発は、実践的な技能の習得とポートフォリオ作成のための具体的な例として最適だと考えています。

この第 3 部では、ToDo アプリの機能と API リクエストの実装を行います。

todo-app-demo

想定読者

  • react の初学者から中級者
  • react を触ったことはあるが、なにかポートフォリオ的な物を作りたい人

記事構成

  1. ToDo アプリの概要と開発環境の構築
  2. json-server の導入とレイアウトの定義
  3. ToDo アプリの機能と API リクエストの実装
  4. Firebase による認証機能の実装

型の定義

API リクエストの実装を行う前に ToDo の型を定義します。src ディレクトリ内に todo フォルダを作成し、types.ts ファイルを作成します。

// types.ts
export interface TodoState {
  id: string;
  title: string;
  description: string;
  done: boolean;
  important: boolean;
  date: string;
  createdAt: string;
}

 

types.ts の説明

TodoState という名前のインターフェースで、ToDo アイテムに使用する属性の型を定義します。このインターフェースには、ToDo アイテムの一意な ID、タイトル、説明、完了状態、重要度、期日、作成日時といった属性が含まれます。この型定義は、ToDo アプリケーション内でのデータの扱いを一貫性のある形で管理するために使用されます。また、TypeScript を利用することで、これらの属性に関する型安全性を保証し、開発時のエラーを減らすことができます。

API リクエストの実装

API リクエストの実装を行います。具体的には、CRUD(Create, Read, Update, Delete)すなわち取得、追加、変更、削除の機能を実装していきます。

まず、先に作成した todo フォルダ内に api フォルダを新たに作成し、その中に api.ts ファイルを作成します。この api.ts ファイルには、初めに型のインポートのみを行い、後にこのファイルに API リクエストの処理を追記して実装します。

// api.ts
import { TodoState } from "../types";

 

ここまでのフォルダ構成は以下の通りです(一部省略)。

src
├── theme
│   └── index.ts
└── todo
    ├── api
    │   └── api.ts
    └── types.ts

 

ToDo の取得

ToDo を取得するための API リクエストを実装します。ここでは、全ての ToDo を取得する処理と特定の ID を持つ ToDo を取得する処理の二つを実装します。api.tsに追記していきます。

// api.ts
export const getAllTodos = async (): Promise<TodoState[]> => {
  const res = await fetch(`http://localhost:3001/todos`);
  const todos = res.json();
  return todos;
};

export const getTodoById = async (id: string): Promise<TodoState> => {
  const res = await fetch(`http://localhost:3001/todos/${id}`);
  const todo = await res.json();
  return todo;
};

 

各関数の説明

  • getAllTodos
    getAllTodos 関数は、localhost:3001/todos から全ての ToDo を取得します。この関数は非同期であり、返り値は TodoState[] 、すなわち ToDo オブジェクトの配列として型指定されています。fetch 関数を使用して API にリクエストを送り、レスポンスを JSON 形式で取得しています。
  • getTodoByIdgetTodoById 関数は、特定の ID を持つ ToDo を取得するために使われます。この関数も非同期で、引数として id(文字列型)を取り、返り値は TodoState 型です。こちらも fetch 関数を使用して、指定された ID に対応する ToDo の詳細情報を JSON 形式で取得しています。

ToDo の追加

ToDo アイテムを追加するための API リクエストを実装します。

export const addTodo = async (todo: TodoState): Promise<TodoState> => {
  const res = await fetch(`http://localhost:3001/todos`, {
    method: "post",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(todo),
  });
  const newTodo = res.json();
  return newTodo;
};

 

addTodoの説明

addTodo 関数は新しい ToDo アイテムをサーバーに追加するための関数です。この関数は非同期であり、引数として TodoState 型のオブジェクト(todo)を受け取ります。このオブジェクトは新しい ToDo アイテムのデータを含みます。

fetch 関数を使用して localhost:3001/todos に対し POST リクエストを送信しています。リクエストのヘッダーに`Content-Typeをapplication/json`と指定し、リクエストのボディには新しい ToDo アイテムのデータを JSON 形式で送信しています。

レスポンスとしてサーバーから返される新しい ToDo アイテムのデータは、newTodo として受け取り、これを返り値としています。

ToDo の変更

ToDo アイテムを変更するための API リクエストを実装します。

export const updateTodo = async (id: string, todo: TodoState) => {
  await fetch(`http://localhost:3001/todos/${id}`, {
    method: "put",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(todo),
  });
};

 

updateTodoの説明

updateTodo 関数は、特定の ID を持つ ToDo アイテムを更新するための関数です。この関数は非同期であり、引数として ToDo アイテムの id(文字列型)と更新するデータを含む TodoState 型のオブジェクト(todo)を受け取ります。

fetch 関数を使用して、localhost:3001/todos/${id}に対し PUT リクエストを送信しています。リクエストのヘッダーには`Content-Typeをapplication/json`と指定し、リクエストのボディには更新する ToDo アイテムのデータを JSON 形式で送信しています。

この関数はレスポンスを返さないため、単に指定された ToDo アイテムの更新を行うのみです。

ToDo の削除

ToDo アイテムを削除するための API リクエストを実装します。

export const deleteTodo = async (id: string) => {
  await fetch(`http://localhost:3001/todos/${id}`, {
    method: "delete",
    headers: {
      "Content-Type": "application/json",
    },
  });
};

 

deleteTodoの説明

deleteTodo 関数は、特定の ID を持つ ToDo アイテムを削除するための関数です。この関数は非同期で、引数として ToDo アイテムの id(文字列型)を受け取ります。

fetch 関数を使い、localhost:3001/todos/${id}に対して DELETE リクエストを送信します。このリクエストは指定された ID の ToDo アイテムをサーバーから削除します。

ToDo アプリの機能の実装

次に ToDo アプリの機能を実装します。

カスタムフックの実装

まず最初に、ToDo アイテムを取得するためのカスタムフックを実装します。todo フォルダ内に hooks フォルダを作成し、その中に useTodos.ts ファイルを作成します。

todo
├── api
│   └── api.ts
├── hooks
│   └── useTodos.ts
└── types.ts

 

// useTodos.ts
import { useEffect, useState } from "react";
import { getAllTodos } from "../api/api";
import { TodoState } from "../types";

export const useTodos = () => {
  const [todos, setTodos] = useState<TodoState[]>([]);

  const fetchTodos = async () => {
    const todos = await getAllTodos();
    setTodos(todos);
  };

  useEffect(() => {
    fetchTodos();
  }, []);

  return { todos, setTodos };
};

 

useTodos.ts の説明

useTodos は、ToDo アイテムを取得して管理するためのカスタムフックです。useState フックを使用して、ToDo アイテムの配列を状態として保持します(todos)。

fetchTodos 関数は、先ほど作成した getAllTodos API 関数を呼び出して ToDo アイテムを取得し、その結果を todos 状態に設定します。

useEffect フックは、コンポーネントのマウント時に fetchTodos 関数を一度だけ実行するように設定されています(空の依存配列 [] により)。

戻り値は、ToDo アイテムの配列、それを更新する関数になります。

共有コンポーネントの実装

ToDo アイテムを追加するためのコンポーネントを実装する際に利用する、再利用可能な共有コンポーネントを実装します。src ディレクトリ内に components フォルダを作成し、FormikTextField.tsx と SubmitButton.tsx の 2 つのファイルを作成します。これらを再利用可能な形で実装することにより、同様の記述を省略し、コードを簡潔に保つことができます。

src
├── components
│   ├── FormikTextField.tsx
│   └── SubmitButton.tsx

 

FormikTextField.tsx の説明

FormikTextField は Formik フォームライブラリを利用したテキストフィールドコンポーネントです。このコンポーネントは Formik の values、handleChange、handleBlur、errors といった機能を統合し、フォームの入力項目を作成します。

プロパティには、フィールドの name、ラベル、フォームの状態を管理する Formik の formik オブジェクト、そしてテキストフィールドの variant と type が含まれます。

// FormikTextField.tsx
import { TextField } from "@mui/material";
import { FormikProps } from "formik";

interface FormikTextFieldProps<T> {
  name: keyof T;
  label: string;
  formik: FormikProps<T>;
  variant?: "outlined" | "filled" | "standard";
  type?: "text" | "password";
}

// ジェネリック型をコンポーネントに適用
export const FormikTextField = <T extends {}>(
  props: FormikTextFieldProps<T>
) => {
  return (
    <TextField
      size="small"
      id={String(props.name)}
      name={String(props.name)}
      label={String(props.label)}
      type={String(props.type)}
      variant={props.variant}
      fullWidth
      value={props.formik.values[props.name]}
      onChange={props.formik.handleChange}
      onBlur={props.formik.handleBlur}
      error={
        !!(props.formik.touched[props.name] && props.formik.errors[props.name])
      }
      helperText={String(
        props.formik.touched[props.name] && props.formik.errors[props.name]
          ? props.formik.errors[props.name]
          : ""
      )}
    />
  );
};

 

SubmitButton.tsx の説明

SubmitButton は、フォーム送信とキャンセルのためのボタンを表示するコンポーネントです。startIcon でアイコンを設定でき、name でボタンのテキストを指定します。

handleClose はキャンセルボタンがクリックされたときに呼び出される関数です。

// SubmitButton.tsx
import { Box, Button } from "@mui/material";
import React from "react";

interface SubmitButtonProps {
  startIcon?: React.ReactElement;
  name: string;
  handleClose: () => void;
}

const SubmitButton: React.FC<SubmitButtonProps> = (props) => {
  const handleCancel = () => {
    props.handleClose();
  };

  return (
    <Box
      sx={{
        display: "flex",
        justifyContent: "flex-end",
        pt: 3,
      }}
    >
      <Button sx={{ mr: 2 }} onClick={handleCancel}>
        Cancel
      </Button>
      <Button
        startIcon={props.startIcon}
        variant="contained"
        color="primary"
        type="submit"
      >
        {props.name}
      </Button>
    </Box>
  );
};

export default SubmitButton;

 

ToDo アイテムを追加するためのコンポーネントの実装

ToDo アイテムを追加するためのコンポーネントを実装します。todo フォルダ内に、components フォルダを作成し、その中に TodoAdd.tsx ファイルを作成します。

todo
├── components
│   └── TodoAdd.tsx

 

以下のライブラリをインストールするために、frontend ディレクトリに移動し、以下のコマンドを実行します。

npm i --save-dev @types/uuid
npm install @mui/x-date-pickers dayjs
npm i date-fns

 

それぞれのライブラリの概要と使用目的を説明します。

  1. @types/uuid
    @types/uuid は、uuid ライブラリのための TypeScript 型定義を提供するパッケージです。uuid ライブラリは、一意な識別子(UUID)を生成するために使用されます。
  2. @mui/x-date-pickers
    @mui/x-date-pickers は、Material-UI の拡張ライブラリで、日付や時間を選択するための UI コンポーネントを提供します。
  3. dayjs
    dayjs は、日付を操作およびフォーマットするためのライブラリです。
  4. date-fns
    date-fns は、日付操作のための別の JavaScript ライブラリで、多くの便利な関数を提供します。

 

次に TodoAdd.tsx の中身を実装していきます。

// TodoAdd.tsx
import {
  Button,
  Dialog,
  DialogContent,
  DialogTitle,
  IconButton,
  Stack,
} from "@mui/material";
import { Box } from "@mui/system";
import React, { useState } from "react";
import AddIcon from "@mui/icons-material/Add";
import { useFormik } from "formik";
import * as Yup from "yup";
import { v4 as uuidv4 } from "uuid";
import { FormikTextField } from "../../components/FormikTextField";
import SubmitButton from "../../components/SubmitButton";
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { addTodo } from "../api/api";
import { format } from "date-fns";
import dayjs from "dayjs";
import CloseIcon from "@mui/icons-material/Close";
import { TodoState } from "../types";

const validationSchema = Yup.object().shape({
  title: Yup.string().required("Title is required"),
});

interface AddTodoProps {
  todos: TodoState[];
  setTodos: React.Dispatch<React.SetStateAction<TodoState[]>>;
}

const AddTodo: React.FC<AddTodoProps> = (props) => {
  const [open, setOpen] = useState(false);

  const handleClose = () => {
    setOpen(false);
    formik.resetForm();
  };

  const handleTodoAdd = () => {
    setOpen(true);
  };

  const formik = useFormik({
    initialValues: {
      id: "",
      title: "",
      description: "",
      done: false,
      important: false,
      date: "",
      createdAt: "",
    },
    validationSchema,
    onSubmit: async (state) => {
      const newId = uuidv4();
      const todo = {
        ...state,
        id: newId,
        createdAt: format(new Date(), "yyyy-MM-dd HH:mm:ss"),
      };
      await addTodo(todo);
      props.setTodos([...props.todos, todo]);
      handleClose();
    },
  });

  return (
    <Box>
      <Button
        startIcon={<AddIcon />}
        variant="contained"
        sx={{
          p: 2,
          display: "flex",
          justifyContent: "flex-start",
          width: "100%",
        }}
        onClick={handleTodoAdd}
      >
        Add a Todo
      </Button>
      <Dialog open={open} onClose={handleClose}>
        <DialogTitle>Add Todo</DialogTitle>
        <IconButton
          aria-label="close"
          onClick={handleClose}
          sx={{
            position: "absolute",
            right: 8,
            top: 8,
            color: (theme) => theme.palette.grey[500],
          }}
        >
          <CloseIcon />
        </IconButton>
        <DialogContent>
          <form noValidate onSubmit={formik.handleSubmit}>
            <Stack spacing={2}>
              <FormikTextField
                name="title"
                label="Title"
                formik={formik}
                variant="standard"
              />
              <FormikTextField
                name="description"
                label="Description"
                formik={formik}
                variant="standard"
              />
              <LocalizationProvider dateAdapter={AdapterDayjs}>
                <DatePicker
                  label="Deadline"
                  value={null}
                  onChange={(date) => {
                    formik.setFieldValue(
                      "date",
                      dayjs(date).format("YYYY-MM-DD")
                    );
                  }}
                  sx={{ width: "100%", mt: 1 }}
                  slotProps={{
                    textField: {
                      variant: "standard",
                    },
                  }}
                />
              </LocalizationProvider>
            </Stack>
            <SubmitButton name="add" handleClose={handleClose} />
          </form>
        </DialogContent>
      </Dialog>
    </Box>
  );
};

export default AddTodo;

 

TodoAdd.tsx の説明

「ADD A TODO」ボタンをクリックすると、ダイアログが表示され、タイトル(Title)、説明(Description)、期限(Deadline)を入力することができます。入力後、「ADD」ボタンを押すと、Formik を使用して json-server に対して POST リクエストが送信され、ToDo アイテムがデータベースに格納されます。データベースへの格納と同時に、props の setTodos 関数を通じてアプリケーションの状態が更新され、ToDo アイテムがリアルタイムでブラウザに反映されます。

ここで重要な Formik の役割について補足すると、Formik はフォームの状態管理やバリデーションを簡単に行うためのライブラリです。この場合、Formik はフォームの入力値を管理し、バリデーションルールに基づいてエラーチェックを行います。

ユーザーがフォームに入力し、「ADD」ボタンを押すと、Formik の onSubmit メソッドが呼び出され、新しい ToDo アイテムが json-server に POST されます。成功した場合、setTodos 関数によってアプリケーションの状態が更新され、新しい ToDo アイテムが UI に即座に表示されます。

ToDo アイテムを表示するためのコンポーネントの実装

ToDo アイテムを表示するためのコンポーネントを実装します。todo フォルダ内に、TodoList.tsx ファイルを作成し、以下のように実装します。

// TodoList.tsx
import { Box, Checkbox, Typography } from "@mui/material";
import React from "react";
import CircleOutlinedIcon from "@mui/icons-material/CircleOutlined";
import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined";
import StarBorderOutlinedIcon from "@mui/icons-material/StarBorderOutlined";
import StarIcon from "@mui/icons-material/Star";
import { updateTodo } from "./api/api";
import { TodoState } from "./types";

interface TodoListProps {
  todos: TodoState[];
  setTodos: React.Dispatch<React.SetStateAction<TodoState[]>>;
  filterFunction: (todo: TodoState) => boolean;
}

const TodoList: React.FC<TodoListProps> = (props) => {
  const fileteredTodos = props.todos.filter(props.filterFunction);

  const handleCheckDone = async (todo: TodoState) => {
    const updateData = { ...todo, done: !todo.done };
    await updateTodo(todo.id, updateData);

    props.setTodos(props.todos.map((t) => (t.id === todo.id ? updateData : t)));
  };

  const handleCheckImportant = async (todo: TodoState) => {
    const updateData = { ...todo, important: !todo.important };
    await updateTodo(todo.id, updateData);

    props.setTodos(props.todos.map((t) => (t.id === todo.id ? updateData : t)));
  };

  return (
    <Box
      sx={{
        p: 3,
        flexGrow: 1,
        justifyContent: "space-between",
        display: "flex",
        flexDirection: "column",
      }}
    >
      <Box>
        <Typography fontSize={22} align="left" pb={2}>
          Tasks
        </Typography>
        <Box sx={{ overflowY: "auto", maxHeight: "75vh" }}>
          {fileteredTodos.map((todo, index) => (
            <Box
              key={todo.id}
              sx={{
                mb: 1,
                cursor: "pointer",
                bgcolor: "secondary.main",
                color: "secondary.contrastText",
                "&:hover": {
                  bgcolor: "secondary.light",
                },
              }}
            >
              <Box
                key={index}
                sx={{
                  display: "flex",
                  p: 2,
                  justifyContent: "space-between",
                  alignItems: "center",
                }}
              >
                <Box sx={{ display: "flex", alignItems: "center" }}>
                  <Checkbox
                    icon={<CircleOutlinedIcon />}
                    checkedIcon={<CheckCircleOutlinedIcon />}
                    checked={todo.done}
                    onChange={() => handleCheckDone(todo)}
                    onClick={(e: React.MouseEvent<HTMLButtonElement>) =>
                      e.stopPropagation()
                    }
                  />
                  <Box
                    sx={{
                      pl: 2,
                      display: "flex",
                      flexDirection: "column",
                      alignItems: "flex-start",
                    }}
                  >
                    <Box>{todo.title}</Box>
                    <Box>{todo.date}</Box>
                  </Box>
                </Box>
                <Checkbox
                  icon={<StarBorderOutlinedIcon />}
                  checkedIcon={<StarIcon />}
                  checked={todo.important}
                  onChange={() => handleCheckImportant(todo)}
                  onClick={(e: React.MouseEvent<HTMLButtonElement>) =>
                    e.stopPropagation()
                  }
                />
              </Box>
            </Box>
          ))}
        </Box>
      </Box>
    </Box>
  );
};

export default TodoList;

 

TodoList.tsx の説明

json-server に格納されているデータを表示するコンポーネントです。props の filterFunction 関数によって props.todos 配列から ToDo アイテムをフィルタリングし、fileteredTodos という新しい配列を生成しています。この関数は、表示する ToDo アイテムを決定するための条件を定義するために使用されます。ToDo アイテムの状態を更新する処理が handleCheckDone と handleCheckImportant であり、done 状態を切り替えたり、important 状態を切り替えることができます。

ToDo アイテムを編集するためのコンポーネントの実装

次に先程追加した ToDo アイテムを編集するためのコンポーネントを実装していきます。

まずは編集したい ToDo アイテム選択した際に uuid をブラウザの url に使用するために必要なフックを実装します。todo フォルダの hooks 内に useModalRoute.ts ファイルを作成します。

todo
├── hooks
│   ├── useModalRoute.ts
│   └── useTodos.ts

 

recoil をインストールします。

npm install recoil

 

Recoil は、Facebook のチームによって開発された状態管理ライブラリで、コンポーネント間の props のバケツリレーを避けることができます。これにより、アプリケーション全体で状態をグローバルに共有し、よりシンプルなコーディングを実現できます。

// useModalRoute.ts
import { Location, useLocation, useNavigate } from "react-router-dom";
import { atom, useRecoilState } from "recoil";

export type BackgroundLocation = { background: Location | undefined };

const backgroundLocationState = atom<BackgroundLocation>({
  key: "backgroundLocation",
  default: {
    background: undefined,
  },
});

const useModalRoute = () => {
  const navigate = useNavigate();
  const location = useLocation();
  const [backgroundLocation, setBackgroundLocation] = useRecoilState(
    backgroundLocationState
  );

  const editModalPath = (to: string, id: string, pathname: string) => {
    setBackgroundLocation({ background: location });
    navigate(to, {
      state: { background: location, id: id, pathname: pathname },
    });
  };

  return { editModalPath };
};

export default useModalRoute;

 

useModalRoute.ts の説明

このフックは、アプリケーション内の特定の場所でモーダルウィンドウを開く際の背景ルート(現在のページの状態)を管理し、モーダルウィンドウへの遷移を制御するために使用されます。

todo フォルダ内の components に TodoEdit.tsx ファイルを作成します。

todo
├── components
│   ├── TodoAdd.tsx
│   └── TodoEdit.tsx
// TodoEdit.tsx
import {
  Alert,
  Button,
  Checkbox,
  Dialog,
  DialogContent,
  DialogTitle,
  Divider,
  IconButton,
  Popover,
  Snackbar,
  Stack,
  TextField,
  Typography,
} from "@mui/material";
import { Box } from "@mui/system";
import React, { useEffect, useState } from "react";
import { TodoState } from "../types";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import CircleOutlinedIcon from "@mui/icons-material/CircleOutlined";
import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined";
import { deleteTodo, getTodoById, updateTodo } from "../api/api";
import CloseIcon from "@mui/icons-material/Close";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import * as Yup from "yup";
import DeleteIcon from "@mui/icons-material/Delete";
import StarIcon from "@mui/icons-material/Star";
import { useFormik } from "formik";
import SubmitButton from "../../components/SubmitButton";
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs from "dayjs";

interface TodoEditProps {
  todos: TodoState[];
  setTodos: React.Dispatch<React.SetStateAction<TodoState[]>>;
}

const TodoEdit: React.FC<TodoEditProps> = (props) => {
  const { id } = useParams<string>();
  const [todo, setTodo] = useState<TodoState>();
  const navigate = useNavigate();
  const location = useLocation();
  const pathname = location.state.pathname;

  const validationSchema = Yup.object().shape({
    title: Yup.string().required("Title is required"),
  });

  const [actionOpen, setActionOpen] = useState(false);
  const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(
    null
  );
  const [snacBarOpen, setSnackBarOpen] = useState(false);

  const handleClose = () => {
    navigate(pathname);
    handleSnackBarClose();
  };

  const handleActionOpen = (e: React.MouseEvent<HTMLButtonElement>) => {
    setActionOpen(true);
    setAnchorEl(e.currentTarget);
  };

  const handleActionClose = () => {
    setActionOpen(false);
  };

  const handleSnackBarClose = () => {
    setSnackBarOpen(false);
  };

  const handleCheckImportant = async (todo: TodoState) => {
    const editTodo = props.todos.find(
      (currentTodo) => currentTodo.id === todo.id
    );
    if (editTodo) {
      const updateData = { ...editTodo, important: !editTodo.important };
      await updateTodo(todo.id, updateData);
      props.setTodos(
        props.todos.map((currentTodo) =>
          currentTodo.id === todo.id ? updateData : currentTodo
        )
      );
      setTodo(updateData);
    }
  };

  const handleCheckDone = async (todo: TodoState) => {
    const editTodo = props.todos.find(
      (currentTodo) => currentTodo.id === todo.id
    );
    if (editTodo) {
      const updateData = { ...editTodo, done: !editTodo.done };
      await updateTodo(todo.id, updateData);
      props.setTodos(
        props.todos.map((currentTodo) =>
          currentTodo.id === todo.id ? updateData : currentTodo
        )
      );
      setTodo(updateData);
    }
  };

  const handleDeleteTodo = async (todo: TodoState) => {
    const newTodos = props.todos.filter(
      (currentTodo) => todo.id !== currentTodo.id
    );
    await deleteTodo(todo.id);
    props.setTodos(newTodos);
    setActionOpen(false);
    navigate(pathname);
  };

  const formik = useFormik({
    enableReinitialize: true,
    initialValues: {
      id: todo?.id || "",
      title: todo?.title || "",
      description: todo?.description || "",
      done: todo?.done || false,
      important: todo?.important || false,
      date: todo?.date || "",
      createdAt: todo?.createdAt || "",
    },
    validationSchema,
    onSubmit: async (state) => {
      if (todo) {
        const updateData = {
          ...todo,
          title: state.title,
          description: state.description,
          date: state.date,
        };
        await updateTodo(todo.id, updateData);
        props.setTodos(
          props.todos.map((currentTodo) =>
            currentTodo.id === todo.id ? updateData : currentTodo
          )
        );
        setSnackBarOpen(true);
      }
    },
  });

  useEffect(() => {
    if (id) {
      const fetchTodo = async () => {
        const fetchedTodo = await getTodoById(id);
        setTodo(fetchedTodo);
      };
      fetchTodo();
    }
  }, [id]);

  if (!todo) {
    return <Box />;
  }

  return (
    <Dialog open={true} onClose={handleClose} fullWidth>
      <DialogTitle>Task Details</DialogTitle>
      <IconButton
        aria-label="close"
        onClick={handleClose}
        sx={{
          position: "absolute",
          right: 8,
          top: 8,
          color: (theme) => theme.palette.grey[500],
        }}
      >
        <CloseIcon />
      </IconButton>
      <Box>
        <IconButton
          aria-label="action"
          onClick={handleActionOpen}
          sx={{
            position: "absolute",
            right: 48,
            top: 8,
            color: (theme) => theme.palette.grey[500],
          }}
        >
          <MoreHorizIcon />
        </IconButton>
        <Popover
          open={actionOpen}
          onClose={handleActionClose}
          anchorEl={anchorEl}
          anchorOrigin={{
            vertical: "bottom",
            horizontal: "right",
          }}
          transformOrigin={{
            vertical: "top",
            horizontal: "right",
          }}
        >
          <Box sx={{ p: 2 }}>
            <Stack spacing={1}>
              <Typography variant="caption">
                追加日: {todo.createdAt}
              </Typography>
              <Divider />
              <Button
                startIcon={<StarIcon />}
                fullWidth
                sx={{ display: "flex", justifyContent: "flex-start" }}
                onClick={() => handleCheckImportant(todo)}
              >
                {todo.important ? "お気に入りから外す" : "お気に入りにする"}
              </Button>
              <Button
                startIcon={<DeleteIcon />}
                fullWidth
                sx={{ display: "flex", justifyContent: "flex-start" }}
                onClick={() => handleDeleteTodo(todo)}
              >
                タスクを削除...
              </Button>
            </Stack>
          </Box>
        </Popover>
      </Box>
      <DialogContent>
        <form noValidate onSubmit={formik.handleSubmit}>
          <Box sx={{ display: "flex" }}>
            <Box sx={{ pr: 1 }}>
              <Checkbox
                icon={<CircleOutlinedIcon />}
                checkedIcon={<CheckCircleOutlinedIcon />}
                checked={todo.done}
                onChange={() => handleCheckDone(todo)}
              />
            </Box>
            <Box
              sx={{ display: "flex", flexDirection: "column", width: "100%" }}
            >
              <Stack spacing={1}>
                <TextField
                  id="title"
                  label="Title"
                  variant="standard"
                  value={formik.values.title}
                  onChange={formik.handleChange}
                  onBlur={formik.handleBlur}
                  error={!!(formik.touched.title && formik.errors.title)}
                  helperText={
                    (formik.touched.title && formik.errors.title) || ""
                  }
                />
                <TextField
                  id="description"
                  label="description"
                  multiline
                  rows={4}
                  variant="standard"
                  value={formik.values.description}
                  onChange={formik.handleChange}
                  onBlur={formik.handleBlur}
                />
                <LocalizationProvider dateAdapter={AdapterDayjs}>
                  <DatePicker
                    label="Deadline"
                    value={
                      formik.values.date ? dayjs(formik.values.date) : null
                    }
                    onChange={(date) => {
                      formik.setFieldValue(
                        "date",
                        date ? dayjs(date).format("YYYY-MM-DD") : null
                      );
                    }}
                    sx={{ width: "100%", mt: 1 }}
                    slotProps={{
                      textField: {
                        variant: "standard",
                      },
                    }}
                  />
                </LocalizationProvider>
              </Stack>
            </Box>
          </Box>
          <SubmitButton name="update" handleClose={handleClose} />
        </form>
        <Snackbar
          open={snacBarOpen}
          autoHideDuration={6000}
          onClose={handleSnackBarClose}
        >
          <Alert onClose={handleSnackBarClose} severity={"success"}>
            Update task
          </Alert>
        </Snackbar>
      </DialogContent>
    </Dialog>
  );
};

export default TodoEdit;

 

TodoEdit.tsx の説明

TodoEdit.tsx は ToDo アイテムの詳細情報を表示し、編集するコンポーネントです。このコンポーネントは、アプリケーションのユーザーがToDoアイテムのタイトル、説明、重要度、完了状態、および期限を編集できます。

TodoList.tsx の修正

ToDo アイテムをクリックした際に編集ができるよう、TodoList.tsx に以下を追記します。まず、useModalRoute カスタムフックと useLocation フックを TodoList.tsx にインポートします。

import useModalRoute from "./hooks/useModalRoute";
import { useLocation } from "react-router-dom";
import AddTodo from "./components/TodoAdd";

 

インポートしたuseModalRoute カスタムフックと useLocation フックを TodoList 内で使用します。

const TodoList: React.FC<TodoListProps> = (props) => {
  const { editModalPath } = useModalRoute();
  const location = useLocation();

 

ToDo アイテムをクリックしたときに呼び出される handleEdit 関数を定義します。この関数は、クリックされた ToDo アイテムの ID と現在のパスネームを editModalPath 関数に渡して、編集モーダルへ遷移します。

const handleEdit = (todo: TodoState) => {
  const pathname = location.pathname;
  editModalPath(`/todos/${todo.id}`, todo.id, pathname);
};

 

また、ToDo アイテムを表示する Box コンポーネントに onClick イベントを追加し、handleEdit 関数がクリック時に実行されるようにします。これにより、ユーザーが ToDo アイテムをクリックすると、該当アイテムの編集画面への遷移が可能になります

<Box
  key={todo.id}
  sx={{
    mb: 1,
    cursor: "pointer",
    bgcolor: "secondary.main",
    color: "secondary.contrastText",
    "&:hover": {
      bgcolor: "secondary.light",
    },
  }}
  onClick={() => handleEdit(todo)} // 追加
>

 

AddTodoコンポーネントを追加します。

        </Box>
      </Box>
      <AddTodo todos={props.todos} setTodos={props.setTodos} /> // 追加
    </Box>
  );
};

export default TodoList;

App.tsx と index.tsx の修正

続いて、App.tsx を以下に修正します。

// App.tsx
import "./App.css";
import { Route, Routes, useLocation } from "react-router-dom";
import AppLayout from "./layout/AppLayout";
import { CssBaseline, ThemeProvider } from "@mui/material";
import { createTheme } from "./theme";
import TodoList from "./todo/TodoList";
import { useTodos } from "./todo/hooks/useTodos";
import { BackgroundLocation } from "./todo/hooks/useModalRoute";
import TodoEdit from "./todo/components/TodoEdit";

function App() {
  const theme = createTheme();
  const { todos, setTodos } = useTodos();
  const location = useLocation();
  const background = (location.state as BackgroundLocation)?.background;

  return (
    <div className="App">
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <Routes location={background || location}>
          <Route path="/" element={<AppLayout />}>
            <Route
              index
              element={
                <TodoList
                  todos={todos}
                  setTodos={setTodos}
                  filterFunction={(todo) => !todo.done}
                />
              }
            />
            <Route
              path="/important"
              element={
                <TodoList
                  todos={todos}
                  setTodos={setTodos}
                  filterFunction={(todo) => todo.important}
                />
              }
            />
            <Route
              path="/all"
              element={
                <TodoList
                  todos={todos}
                  setTodos={setTodos}
                  filterFunction={() => true}
                />
              }
            />
            <Route
              path="/complited"
              element={
                <TodoList
                  todos={todos}
                  setTodos={setTodos}
                  filterFunction={(todo) => todo.done}
                />
              }
            />
            <Route
              path="/todos/:id"
              element={
                <TodoList
                  todos={todos}
                  setTodos={setTodos}
                  filterFunction={(todo) => !todo.done}
                />
              }
            />
          </Route>
        </Routes>
        {background && (
          <Routes>
            <Route
              path="/todos/:id"
              element={<TodoEdit todos={todos} setTodos={setTodos} />}
            />
          </Routes>
        )}
      </ThemeProvider>
    </div>
  );
}

export default App;

 

最後に index.tsx を以下に修正します。

// index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { RecoilRoot } from "recoil";
import { BrowserRouter } from "react-router-dom";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <React.StrictMode>
    <RecoilRoot>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </RecoilRoot>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

 

次のステップ

ここまでの実装で、react を使った ToDo アプリの基本的な実装は完了です。次回の第 4 部は必須では有りませんが、Firebase を用いた認証機能を実装します。

もっと体系的に勉強したい方やバックエンドと組み合わせてフルスタックな実装をしたい方は下記のコースや書籍をおすすめします。

 

まとめ

この記事では、React と TypeScript を使用した ToDo アプリ開発シリーズの第 3 部を紹介しました。第 3 部では、ToDo アプリの主要機能と API リクエストの実装に焦点を当てました。まず、ToDo アイテムの型を定義し、その後 CRUD 処理(ToDo アイテムの取得、追加、変更、削除)を実装しました。アプリの機能に関しては、作成した API リクエストを活用し、実際にブラウザ上で ToDo アイテムを表示するためのコンポーネントを作成しました。シリーズの次回、第 4 部ではFirebase による認証機能の実装を行います。