Reactアプリ100本ノックを実践する 〜04 Timer〜

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

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

今回の元記事はこちら

https://qiita.com/Sicut_study/items/1e4d8ec06f1af42a47e5

# 前回の記事

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

# 問題
タイマーアプリを作成する

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

## 達成条件
**元記事より引用**
1. ユーザーは分と秒を入力してタイマーを設定できる。
2. スタートボタンを押すとカウントダウンが開始される。
3. 時間が0になったら、ユーザーに通知される (効果音が鳴る)
4. 一時停止ボタンを押すとカウントダウンが停止し、再開ボタンでカウントダウンが再開される。
5. リセットボタンでタイマーが設定時間に戻る。
6. 無効な時間（例：負の時間、非数値、60分以上の値）が入力された場合、エラーメッセージが表示される。

# 実装

本記事では以下の方針で実装していきます。
- ボタンとテキストボックスをコンポーネント化する
- `use-sound`ライブラリを利用して効果音を鳴らす

毎回同じコマンドを打つだけなので、今回からはプロジェクトの作成手順は省略します。
また、Emotionは`styled`ではなく`css`の方を使っていこうと思います（個人の好みから）。

各コンポーネントとApp.tsxの実装は以下のようになりました。効果音のmp3ファイルは好きなものを配置してください（[※mp3のimportでエラーが出る場合](#補足)）。

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

interface ButtonProps {
  onClick: () => void;
  disabled: boolean;
  text: string;
}

const buttonStyle = (disabled: boolean) => css`
  margin: 10px;
  padding: 5px 10px;
  background-color: ${disabled ? "#ccc" : "#007bff"};
  color: ${disabled ? "#666" : "white"};
  border: none;
  border-radius: 4px;
  cursor: ${disabled ? "not-allowed" : "pointer"};
  &:hover {
    background-color: ${disabled ? "#ccc" : "#0056b3"};
  }
`;

const Button = ({ onClick, disabled, text }: ButtonProps) => {
  return (
    <button css={buttonStyle(disabled)} onClick={onClick} disabled={disabled}>
      {text}
    </button>
  );
};

export default Button;
```

```typescript:src/components/InputField.tsx
/** @jsxImportSource @emotion/react */
import { css } from "@emotion/react";

interface InputFieldProps {
  label: string;
  value: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

const inputFieldStyle = css`
  margin: 10px;
  label {
    font-weight: bold;
  }
  input {
    margin-left: 5px;
    padding: 5px;
    border: 1px solid #ccc;
    border-radius: 4px;
  }
`;

const InputField = ({ label, value, onChange }: InputFieldProps) => {
  return (
    <div css={inputFieldStyle}>
      <label>
        {label}
        <input type="number" value={value} onChange={onChange} />
      </label>
    </div>
  );
};

export default InputField;
```

```typescript:src/App.tsx
/** @jsxImportSource @emotion/react */
import { useState, useEffect } from "react";
import { css } from "@emotion/react";
import InputField from "./components/InputField";
import Button from "./components/Button";
import useSound from "use-sound";
import beepSound from "./sounds/beep.mp3";

const appStyle = css`
  text-align: center;
  margin-top: 50px;
`;

const errorStyle = css`
  color: red;
  margin-top: 20px;
`;

const App = () => {
  const [minutes, setMinutes] = useState<string>("0");
  const [seconds, setSeconds] = useState<string>("0");
  const [totalSeconds, setTotalSeconds] = useState<number>(0);
  const [isActive, setIsActive] = useState<boolean>(false);
  const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  // useSoundフックでサウンドをセットアップ
  const [play] = useSound(beepSound, { volume: 0.5 });

  useEffect(() => {
    let id: NodeJS.Timeout | null = null;
    if (isActive && totalSeconds > 0) {
      id = setInterval(() => {
        setTotalSeconds((seconds) => seconds - 1);
      }, 1000);
      setIntervalId(id);
    } else if (totalSeconds === 0 && isActive) {
      clearInterval(intervalId as NodeJS.Timeout);
      play(); // 効果音を再生する
      setIsActive(false);
    }
    return () => {
      if (id) clearInterval(id);
    };
  }, [isActive, totalSeconds]);

  const toggleActive = () => {
    setIsActive(!isActive);
  };

  const handleStart = () => {
    const totalSec = parseInt(minutes) * 60 + parseInt(seconds);
    if (!isNaN(totalSec) && totalSec >= 0) {
      setTotalSeconds(totalSec);
      setIsActive(true);
      setErrorMessage(null);
    } else {
      setErrorMessage("Invalid time input!");
    }
  };

  const handleReset = () => {
    setIsActive(false);
    setTotalSeconds(parseInt(minutes) * 60 + parseInt(seconds));
  };

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

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

  return (
    <div css={appStyle}>
      <h1>Timer App</h1>
      {errorMessage && <div css={errorStyle}>{errorMessage}</div>}
      <InputField
        label="Minutes:"
        value={minutes}
        onChange={handleMinutesChange}
      />
      <InputField
        label="Seconds:"
        value={seconds}
        onChange={handleSecondsChange}
      />
      <Button onClick={handleStart} disabled={isActive} text="Start" />
      <Button
        onClick={toggleActive}
        disabled={!isActive && totalSeconds === 0}
        text={isActive ? "Pause" : "Resume"}
      />
      <Button
        onClick={handleReset}
        disabled={!isActive && totalSeconds === 0}
        text="Reset"
      />
      <h2>
        Time Remaining: {Math.floor(totalSeconds / 60)}:
        {totalSeconds % 60 < 10 ? `0${totalSeconds % 60}` : totalSeconds % 60}
      </h2>
    </div>
  );
};

export default App;
```

## 補足
mp3をimportするとき、以下のようなエラーが出るかもしれません。

```
Cannot find module './sounds/beep.mp3' or its corresponding type declarations.
```

このようなエラーが出た際には、`src/types/custom.d.ts`を作成し、以下のようなコードを書けば解決できるかと思います。

```typescript:src/types/custom.d.ts
declare module "*.mp3" {
  const src: string;
  export default src;
}
```


# 完成
完成形はこのようになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267522/da0d6426-f2b4-e0d3-c6b4-8bd48b63290b.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267522/f6e359e4-1f99-692a-b109-1cfc49add90c.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267522/8caa5b3e-a493-14a6-75da-86f4ec82e78a.png)


# 最後に
ハイペース投稿でようやく本家のReactアプリ100本ノックに追いつきました。引き続き、100回完走を目指して継続していきます。
応援してくれる方はぜひフォローいただけると嬉しいです。
いいね、ストックもお待ちしております。

ではまた。

# 次回の記事

https://qiita.com/jinto/items/3946598841861714c13d