React100ノックやってみた【04】

この記事は以下の記事を参考に作成しました。

https://qiita.com/Sicut_study/items/1e4d8ec06f1af42a47e5?utm_campaign=post_article&utm_medium=twitter&utm_source=twitter_share

https://zenn.dev/create_quest/articles/ed06f17a3b26bf

## **問題**

タイマーアプリを作成します

## **目的**

タイマーアプリでは、Reactの根幹をなす状態管理、ライフサイクルメソッド、そしてイベント処理の実践を通じて、Reactの基礎を固めます

またsetTimeoutなど指定した時間に何かを実行するという関数を学んでいきます

## **達成条件**

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

## **実際に解いてみた**

### **利用技術**

- React
- TypeScript
- shudcn/ui
- TailwindCSS
- Next.js

## 結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3993206/3bcde398-12a1-295c-e535-96db512042a6.png)

## ディレクトリ構成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3993206/a43a6156-7e57-7775-e249-98094186e4c4.png)

## コード

```tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { ChevronDown, ChevronUp } from 'lucide-react';

const ONE_HOURS = 3600000;
const ONE_MINUTES = 60000;
const ONE_SECONDS = 1000;
const MAX_COUNT = 86400000;
const MIN_COUNT = 0;

export default function Home() {
  /** タイマーの時間 */
  //開始時の値を設定。これが変更されたら再レンダリングさせて新しい値を画面表示させる
  //timerCount=0,timerState='standby'の状態
  const [timerCount, setTimerCount] = useState(0);
  const [timerState, setTimerState] = useState('standby');
  const [isAlarmed, setIsAlarmed] = useState(false);

  const timerIdRef = useRef<NodeJS.Timeout | undefined>(undefined);
  const audioRef = useRef<HTMLAudioElement | null>(null);

  const getHour = (milliseconds: number) => {
    const hh = Math.floor(milliseconds / ONE_HOURS);
    return String(hh).padStart(2, '0');
  };

  const getMinute = (milliseconds: number) => {
    const mm = Math.floor((milliseconds % ONE_HOURS) / ONE_MINUTES);
    return String(mm).padStart(2, '0');
  };
  const getSeconds = (milliseconds: number) => {
    const ss = Math.floor((milliseconds % ONE_MINUTES) / ONE_SECONDS);
    return String(ss).padStart(2, '0');
  };

  /**
   * タイマーの時間を追加する関数
   * @param plusCount
   */

  const plus = (plusCount: number) => {
    if (timerCount + plusCount <= MAX_COUNT) {
      setTimerCount((prevVal) => prevVal + plusCount);
    }
  };

  const minus = (minusCount: number) => {
    if (timerCount - minusCount >= MIN_COUNT) {
      setTimerCount((prevVal) => prevVal - minusCount);
    }
  };

  const start = () => {
    setTimerState('active');
  };
  const stop = () => {
    setTimerState('standby');
  };
  const reset = () => {
    setTimerState('standby');
    setTimerCount(0);
  };

  useEffect(() => {
    //Audioインスタンスを初期化
    audioRef.current = new Audio('/sound/alarm.mp3');
    //クリーンアップ関数
    return () => {
      if (audioRef.current) {
        audioRef.current.pause();
        audioRef.current = null;
      }
    };
  }, []);

  const playSound = () => {
    if (audioRef.current) {
      setIsAlarmed(true);
      audioRef.current.play();
    }
  };

  const stopSound = () => {
    setTimerState('standby');
    if (audioRef.current) {
      setIsAlarmed(false);
      audioRef.current.pause(); //音楽を一時停止
      audioRef.current.currentTime = 0; //再生位置を最初に戻す
    }
  };

  useEffect(() => {
    //タイマーの状態がactiveの時はこの関数の処理が終わる
    if (timerState !== 'active') {
      return;
    }
    //  タイマーのカウントが0を超えているときはタイマーをスタート
    if (timerCount > 0) {
      //setIntervalで一秒（1000ms）おきに、タイマーのカウントを減らす
      //setIntervalのIDをRefに保持しておく、そのため再レンダリングが走らない
      timerIdRef.current = setInterval(() => {
        setTimerCount((prevVal1) => prevVal1 - ONE_SECONDS);
      }, ONE_SECONDS);
    } else {
      //タイマーカウントが0になったら、clearTntervalでタイマーを止め、タイマーの状態をEndにする
      clearInterval(timerIdRef.current);
      setTimerState('end');
      playSound();
    }

    //クリーンアップ関数 useEffectが再実行される前や、コンポーネントのアンマウント時に呼び出され、古いタイマーを制止する。
    //これがないと、ストップやリセットボタンを押しても、タイマーが止まらない。
    return () => {
      if (timerIdRef.current) {
        clearInterval(timerIdRef.current);
      }
    };
  }, [timerState, timerCount]);

  return (
    <div className="flex h-svh flex-col items-center justify-center gap-8">
      <div className="flex justify-center gap-8"></div>
      <div className="flex justify-center space-x-2"></div>
      <div className="flex justify-center gap-8">
        <Card className="min-w-[400px]">
          <CardContent className="min-w-screen-md w-full px-6 py-8">
            <div className="grid w-full grid-cols-3 gap-4 text-center text-[72px] font-bold">
              {[ONE_HOURS, ONE_MINUTES, ONE_SECONDS].map((value, index) => (
                <div className="flex items-center justify-center" key={index.toString()}>
                  <Button variant="outline" size="icon" onClick={() => plus(value)}>
                    <ChevronUp />
                  </Button>
                </div>
              ))}
              <p>{getHour(timerCount)}</p>
              <p className="relative">
                <span className="absolute -left-2 top-1/2 -translate-x-1/2 -translate-y-1/2 pb-2">
                  :
                </span>
                {getMinute(timerCount)}
              </p>
              <p className="relative">
                <span className="absolute -left-2 top-1/2 -translate-x-1/2 -translate-y-1/2 pb-2">
                  :
                </span>
                {getSeconds(timerCount)}
              </p>
              {[ONE_HOURS, ONE_MINUTES, ONE_SECONDS].map((value, index) => (
                <div className="flex items-center justify-center" key={index.toString()}>
                  <Button variant="outline" size="icon" onClick={() => minus(value)}>
                    <ChevronDown />
                  </Button>
                </div>
              ))}
              <Button onClick={start} disabled={timerState === 'active' || isAlarmed}>
                start
              </Button>
              <Button onClick={stop} disabled={timerState === 'standby' || isAlarmed}>
                stop
              </Button>
              <Button onClick={reset} disabled={isAlarmed}>
                reset
              </Button>
              <Button className="col-start-2" onClick={stopSound} disabled={!isAlarmed}>
                Stop Alarm
              </Button>
            </div>
          </CardContent>
        </Card>
      </div>
    </div>
  );
}

```

## 感想
userefやuseEffectについての理解を深められた。