React、TypeScript、Next.js+APIを使用してToDoリストを作成してみた

## はじめに
今回はReact、TypeScript、Next.jsを使用してToDoリストを作成したいと思います。
この記事を書くにあたり、Trelloをモデルとしたクローンアプリの作成の経過の中で使用したことを記述しております。
バックエンド側となるAPIの作成については、下記記事をご覧ください。


>Node.js+MySQL+Dockerを使用したAPIの作成
>https://qiita.com/t_k_t/items/93567a7ebe912da575c7


## 環境
MacBook M1
React
TypeScript
Next.js
Material UI
Docker

## 1.環境構築

Dockerを使用しての環境構築となりますので、docker-composeを下記のように記述します。
前回記事の内容に追記する形で記述していきます。

````diff_docker 

services:
  db:
    image: mysql:8.0.33
    #Macbook m1を使用しているためplantformを指定
    platform: linux/amd64
    container_name: mysql
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: ユーザ名
      MYSQL_PASSWORD: パスワード
      MYSQL_DATABASE: trello
      TZ: "Asia/Tokyo"
    volumes:
      - db-data:/var/lib/mysql
    ports:
      - 3307:3306

  backend:
    image: node:18-slim
    volumes:
      - ./backend:/usr/src/app
    working_dir: /usr/src/app
    command: bash -c "npm install && npm start"
    ports:
      - 3000:3000
    depends_on:
      - db
    #URLはlocalhostではなく、docker-composeで指定しているservicesのdbを指定
    environment:
      DATABASE_URL: mysql://ユーザー名:パスワード@db:3306/trello

+  frontend:
+    build: ./frontend
+    volumes:
+      - ./frontend:/usr/src/app
+      - /app/node_modules
+    working_dir: /usr/src/app
+    ports:
+      - 8080:8080
+    depends_on:
+      - backend

volumes:
  db-data:

````

ルートディレクトリにて、下記コマンドを実行しプロジェクトを作成します。
今回名称はfrontendとします。

````
npx create-next-app@latest --typescript
````

作成したfrontend直下にdockerfileを作成します。

```` dockerfile

FROM node:18-slim

WORKDIR /usr/src/app

COPY package*.json ./

RUN npm install

COPY . .

EXPOSE 8080

CMD [ "npm", "run", "dev" ]
````


## 実装

実装にあたり不要なファイルであるプロジェクト作成の際に自動生成される/src配下を全て削除します。

削除後、下記画像のように/src配下に各種フォルダとファイルを作成しました。

![スクリーンショット 2023-07-19 15.32.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3495347/a013be1c-6d81-c1e5-ebf0-e398882c94ce.png)


/src/components
```` footer.tsx

import React from "react";

const Footer:React.FC = () => (
    <footer>
        <p>2023 trelloクローンアプリ制作</p>
    </footer>
);

export default Footer;
````
/src/pages
```` index.tsx
import Link from "next/link";
import Footer from "@/components/footer";
import { useEffect, useState } from "react";
import {
  AppBar,
  Button,
  Card,
  CardContent,
  CardHeader,
  IconButton,
  MenuItem,
  TextField,
  ThemeProvider,
  Toolbar,
  Typography,
  createTheme,
  makeStyles,
  styled,
} from "@mui/material";

import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import CancelPresentationIcon from "@mui/icons-material/CancelPresentation";

declare module "@mui/material/styles" {
  interface Theme {
    status: {
      danger: React.CSSProperties["color"];
    };
  }
  interface ThemeOptions {
    status: {
      danger: React.CSSProperties["color"];
    };
  }
  interface Palette {
    neutral: Palette["primary"];
  }
  interface PaletteOptions {
    neutral: PaletteOptions["primary"];
  }
  interface PaletteColor {
    darker?: string;
  }
  interface SimplePaletteColorOptions {
    darker?: string;
  }
}

interface Board {
  id: number;
  name: string;
  board_order: number;
}

const theme = createTheme({
  status: {
    danger: "#64c385",
  },
  palette: {
    primary: {
      main: "#6ae2d0",
      darker: "#6f8a97",
    },
    neutral: {
      main: "#07da8d",
      contrastText: "#fff",
    },
    error: {
      main: "#bced8f",
    },
  },
});

const ActionArea = styled("div")({
  display: "flex",
  alignItems: "center",
  marginBottom: "16px",
});

const StyledTextField = styled("div")({
  display: "flex",
  alignItems: "center",
  marginBottom: "16px",
});

const StyledCard = styled(Card)({
  backgroundColor: "#f5f5f5",
  margin: "16px",
});

const CardContainer = styled("div")({
  display: "flex",
  flexWrap: "wrap",
  justifyContent: "space-second",
});

const CardContentStyled = styled(CardContent)({
  position: "relative",
});

const DeleteButton = styled(Button)({
  backgroundColor: theme.palette.error.main,
  width: "20px",
  height: "20px",
  margin: "16px",
  color: "white",
  "&:hover": {
    backgroundColor: theme.palette.error.dark,
  },
});

export default function Home() {
  const [boards, setBoards] = useState<Board[]>([]);
  const [createBoard, setCreatBoard] = useState("");

  const classes = useState();

  const getBoards = () => {
    fetch("http://0.0.0.0:3000/boards")
      .then((response) => response.json())
      .then((data) => {
        setBoards(
          data.sort((a: Board, b: Board) => a.board_order - b.board_order)
        );
      })
      .catch((error) => {
        console.log(error);
      });
  };

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

  const handleCreateBoard = () => {
    let createBoardOrder: number =
      boards.length === 0
        ? 1
        : Math.max(...boards.map((board) => board.board_order)) + 1;

    fetch("http://0.0.0.0:3000/boards", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        name: createBoard,
        board_order: createBoardOrder,
      }),
    })
      .then((response) => {
        if (!response.ok) {
          throw new Error("レスポンスエラー");
        }
        getBoards();
        setCreatBoard("");
      })
      .catch((error) => {
        console.log(error);
      });
  };

  const handleDeleteBoard = (id: number) => {
    fetch(`http://0.0.0.0:3000/boards/${id}`, {
      method: "DELETE",
    })
      .then(() => {
        setBoards(boards.filter((board) => board.id !== id));
      })
      .catch((error) => {
        console.log(error);
      });
  };

  return (
    <div>
      <ThemeProvider theme={theme}>
        <AppBar position="static">
          <Toolbar>
            <IconButton edge="start" color="inherit">
              <MenuItem />
            </IconButton>
            <Typography variant="h6">trello clone</Typography>
          </Toolbar>
        </AppBar>
        <main>
          <ActionArea>
            <StyledTextField>
              <TextField
                label="新規作成"
                type="text"
                value={createBoard}
                margin="normal"
                onChange={(e) => setCreatBoard(e.target.value)}
              />
              <Button
                variant="contained"
                color="primary"
                onClick={handleCreateBoard}
              >
                <AddCircleOutlineIcon />
              </Button>
            </StyledTextField>
          </ActionArea>
          <CardContainer>
            {boards.map((board) => (
              <StyledCard key={board.id}>
                <CardHeader
                  title={board.name}
                  action={
                    <DeleteButton
                      variant="contained"
                      size="small"
                      onClick={() => handleDeleteBoard(board.id)}
                    >
                      <CancelPresentationIcon />
                    </DeleteButton>
                  }
                />
              </StyledCard>
            ))}
          </CardContainer>
        </main>
        <Footer />
      </ThemeProvider>
    </div>
  );
}


````


今回は、Material UIを採用しました。
使用方法は公式リファレンスを確認してください。
> https://mui.com/

Material UIでは、CSSに記述をすることなくindex.tsx内でCSSのように記述ができます。
また、アイコンも豊富に用意されており使いやすく助かりました。


例えば、今回はdivタグの役割を継承した、ActionAreaを作成し
````　index.tsx
const ActionArea = styled("div")({
  display: "flex",
  alignItems: "center",
  marginBottom: "16px",
});
````

JSX内でdivタグをと同じようにActionAreaを使います。
新規作成の欄を調節するために作成しました。


````index.tsx

           <ActionArea>
            <StyledTextField>
              <TextField
                label="新規作成"
                type="text"
                value={createBoard}
                margin="normal"
                onChange={(e) => setCreatBoard(e.target.value)}
              />
              <Button
                variant="contained"
                color="primary"
                onClick={handleCreateBoard}
              >
                <AddCircleOutlineIcon />
              </Button>
            </StyledTextField>
          </ActionArea>

````

```` package.json

    "dev": "next dev -p 8080",
````

:::note warn
package.jsonについて、docker-composeで設定したポートを指定するようにしてください
:::


前回の記事で作成したAPIからデータを取得するため、APIで指定したURLをfetchしています。
```` index.tsx
const getBoards = () => {
    fetch("http://0.0.0.0:3000/boards")
      .then((response) => response.json())
      .then((data) => {
        setBoards(
          data.sort((a: Board, b: Board) => a.board_order - b.board_order)
        );
      })
      .catch((error) => {
        console.log(error);
      });
  };
````


また、更新や削除の際は、methodやheader,bodyを状況に合わせ指定してfetchしています。


````index.tsx

fetch("http://0.0.0.0:3000/boards", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        name: createBoard,
        board_order: createBoardOrder,
      }),
    })
````


以上全て完了しましたら、下記コマンドでDockerを起動していきます。


````
docker-compose up -d
````
起動が完了するとDocker Desktop下記のように表示されるかと思います。

![スクリーンショット 2023-07-19 14.18.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3495347/15ae86f4-b2b3-a741-ff4d-70a19a6e16d3.png)

では、この状態でhttp://localhost:8080/
にアクセスしてみると下記のGifのように動作します。



<img width="700" alt="タイトルなし.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3495347/6be6fe4a-2256-03ee-857c-ea9545a8b129.gif">


機能として、簡単に２点となります。
新規作成の入力タブからのToDoリスト作成、リスト名称の右側に配置している削除ボタンからのリスト削除です。

![スクリーンショット 2023-07-20 10.11.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3495347/c0c935bf-b6cf-af0b-9775-ed23bac624ee.png)


以上で、ToDoリストの完成です。



## 最後に

最終的にはここからさらに発展させ、trelloクローンを作成させるつもりです。
trelloであれば、今回作成したToDoリストをボードと見立て、リンクを付与し、ページ遷移、そこからカード作成、リスト作成と発展させていく予定です。