Reactアプリ100本ノックを実践する 〜03 TODO〜

# はじめに
こちらの記事は、@Sicut_studyさんがアップしている【Reactアプリ100本ノック】シリーズに相乗りし、アウトプットを行うための記事になります。
- 実装ルールや成果物の達成条件は元記事に従うものとします。
- 元記事との差別点として、具体的に自分がどんな実装を行ったのか（と必要に応じて解説）を記載します。

@Sicut_studyさんのノック100本についていき、Reactを100日間学ぶのが目標です。

今回の元記事はこちら

https://qiita.com/Sicut_study/items/5dec3c42c8f3c90f0487

# 前回の記事

https://qiita.com/jinto/items/df4145b4ed79e81d3590

# 問題
TODOリストアプリを作成する

## ルール
**元記事より引用**
- 主要なライブラリやフレームワークはReactである必要がありますが、その他のツールやライブラリ（例: Redux, Next.js, Styled Componentsなど）を組み合わせて使用することは自由
- TypeScriptを利用する

## 達成条件
**元記事より引用**
1. 入力フィールド: ユーザーがタスクを入力するためのテキストフィールドが存在する。タスク名が1文字以上でない場合はバリデーションする
2. 追加ボタン: 入力されたタスクをTODOリストに追加するためのボタン。
3. タスクリスト表示: 追加されたタスクがリストとして表示される。
4. 削除ボタン: 各タスクの隣には削除ボタンがあり、それをクリックすることで該当のタスクをリストから削除する


# 実装

本記事では以下の方針で実装していきます。
- 元記事と同様に、タスクの登録はモーダルで実現する
- 各パーツ（ボタン、モーダル、TODOリスト）はコンポーネント化する

それでは、

```bash
npx create-react-app react-todo-app --template typescript
```

で新規プロジェクトを作成し、各コンポーネントとApp.tsxの実装を行います。

<details><summary>src/components/Button.tsx</summary>

```typescript:src/components/Button.tsx
/** @jsxImportSource @emotion/react */
import styled from "@emotion/styled";

interface ButtonProps {
  color?: string;
}

const Button = styled.button<ButtonProps>`
  padding: 10px 15px;
  min-width: 120px;
  border: none;
  border-radius: 20px;
  color: white;
  cursor: pointer;
  background-color: ${(props) => props.color || "#757575"};
  box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.15);
  transition: background-color 0.3s, box-shadow 0.3s;

  &:hover {
    box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.2);
    opacity: 0.85;
  }
`;

Button.defaultProps = {
  color: "#757575",
};

export default Button;
```

</details>

<details><summary>src/components/Modal.tsx</summary>

```typescript:src/components/Modal.tsx
/** @jsxImportSource @emotion/react */
import { useState, ChangeEvent } from "react";
import styled from "@emotion/styled";
import Button from "./Button";

const ButtonContainer = styled.div`
  display: flex;
  gap: 10px;
  justify-content: center;
`;

const StyledModal = styled.div`
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
`;

const ModalContent = styled.div`
  background-color: white;
  padding: 40px;
  border-radius: 10px;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  width: 90%;
  max-width: 500px;
  display: flex;
  flex-direction: column;
  gap: 20px;
`;

const StyledInput = styled.input`
  padding: 15px;
  border: 2px solid #ccc;
  border-radius: 8px;
  font-size: 16px;
  width: 100%;
  box-sizing: border-box;
`;

const ErrorMessage = styled.div`
  color: #f44336;
  margin-top: -10px;
  margin-bottom: 10px;
`;

type ModalProps =  {
  onAdd: (task: string) => void;
  onCancel: () => void;
}

const Modal = ({ onAdd, onCancel }: ModalProps) => {
  const [task, setTask] = useState("");
  const [error, setError] = useState("");

  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    setTask(e.target.value);
    if (error) setError("");
  };

  const handleAdd = () => {
    if (task.trim().length > 0) {
      onAdd(task);
      setTask("");
    } else {
      setError("タスク名は1文字以上でなければなりません。");
    }
  };

  const stopPropagation = (e: React.MouseEvent<HTMLDivElement>) => {
    e.stopPropagation();
  };

  return (
    <StyledModal>
      <ModalContent onClick={stopPropagation}>
        <StyledInput
          type="text"
          value={task}
          onChange={handleInputChange}
          placeholder="新しいタスクを入力"
          aria-describedby="error-message"
        />
        {error && <ErrorMessage id="error-message">{error}</ErrorMessage>}
        <ButtonContainer>
          <Button color="#2979ff" onClick={handleAdd}>
            追加
          </Button>
          <Button color="#f44336" onClick={onCancel}>
            キャンセル
          </Button>
        </ButtonContainer>
      </ModalContent>
    </StyledModal>
  );
};

export default Modal;
```

</details>

<details><summary>src/components/TaskList.tsx</summary>

```typescript:src/components/TaskList.tsx
/** @jsxImportSource @emotion/react */
import styled from "@emotion/styled";
import Button from "./Button";

const ListContainer = styled.div`
  border: 1px solid #ddd;
  border-radius: 4px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  width: 100%;
  max-width: 400px;
  background-color: white;
  padding: 20px;
  margin: 20px 0;
`;

const ListItem = styled.div`
  padding: 10px;
  border-bottom: 1px solid #eee;
  display: flex;
  justify-content: space-between;
  align-items: center;

  &:last-child {
    border-bottom: none;
  }
`;

const ListTitle = styled.div`
  font-size: 20px;
  font-weight: bold;
  margin-bottom: 10px;
  text-align: center;
`;

type TaskListProps = {
  tasks: string[];
  onDelete: (index: number) => void;
};

const TaskList = ({ tasks, onDelete }: TaskListProps) => {
  return (
    <ListContainer>
      <ListTitle>TODOリスト</ListTitle>
      {tasks.length > 0 ? (
        tasks.map((task, index) => (
          <ListItem key={index}>
            {task}
            <Button color="#f44336" onClick={() => onDelete(index)}>
              削除
            </Button>
          </ListItem>
        ))
      ) : (
        <ListItem>タスクがありません。</ListItem>
      )}
    </ListContainer>
  );
};

export default TaskList;
```

</details>

<details><summary>src/App.tsx</summary>

```typescript:src/App.tsx
/** @jsxImportSource @emotion/react */
import { useState } from "react";
import styled from "@emotion/styled";
import Button from "./components/Button";
import Modal from "./components/Modal";
import TaskList from "./components/TaskList";

const AppContainer = styled.div`
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background-color: #f7f7f7;
`;

const App = () => {
  const [tasks, setTasks] = useState<string[]>([]);
  const [isModalOpen, setIsModalOpen] = useState(false);

  // タスクの追加処理
  const addTask = (newTask: string) => {
    setTasks((prevTasks) => [...prevTasks, newTask]);
    setIsModalOpen(false); // モーダルを閉じる
  };

  // タスクの削除処理
  const deleteTask = (index: number) => {
    setTasks((prevTasks) => prevTasks.filter((_, i) => i !== index));
  };

  // モーダルの開閉処理
  const toggleModal = () => {
    setIsModalOpen(!isModalOpen);
  };

  return (
    <AppContainer>
      {isModalOpen && <Modal onAdd={addTask} onCancel={toggleModal} />}
      <Button color="#4caf50" onClick={toggleModal}>
        新規登録
      </Button>
      <TaskList tasks={tasks} onDelete={deleteTask} />
    </AppContainer>
  );
};

export default App;
```

</details>

# 完成
今回もデザインはだいぶ改善の余地がありそうですが、要件を満たしているのでOKとします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267522/32dac6d4-32ae-1060-a34f-1b05313aa377.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267522/4d2b4d83-4625-6606-2039-1fe27c75542d.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267522/967dcc70-b548-0bd1-728d-68e411e78c0b.png)


# 最後に
Reactアプリ100本ノックを100回分完走するつもりです。
応援してくれる方はぜひフォローいただけると嬉しいです。
いいね、ストックもお待ちしております。

ではまた。

# 次回の記事

https://qiita.com/jinto/items/1d3deca4939de3528e2d