LoginSignup
11
17

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

Last updated at Posted at 2023-11-12

はじめに

こちらの記事は、@Sicut_studyさんがアップしている【Reactアプリ100本ノック】シリーズに相乗りし、アウトプットを行うための記事になります。

  • 実装ルールや成果物の達成条件は元記事に従うものとします。
  • 元記事との差別点として、具体的に自分がどんな実装を行ったのか(と必要に応じて解説)を記載します。

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

今回の元記事はこちら

前回の記事

問題

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

ルール

元記事より引用

  • 主要なライブラリやフレームワークは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でエラーが出る場合)。

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;
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;
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を作成し、以下のようなコードを書けば解決できるかと思います。

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

完成

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

image.png

image.png

image.png

最後に

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

ではまた。

次回の記事

11
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
17