ツクログネット

【React】カウントダウンタイマーを作る

eye catch

タイムリミットを表示する

はじめに、ブラウザにタイムリミットを表示します。

style.css
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap');

:root {
  --text-color: #333;
  --bg-color: #ffdf2b;
}
App.js
import React from 'react';
import styled from 'styled-components'
import TimeDisplay from './components/TimeDisplay'

const GlobalStyle = createGlobalStyle`
  body {
  	background-color: var(--bg-color);
	  color: var(--text-color);
	  display: flex;
	  align-items: center;
	  justify-content: center;
	  font-family: 'Share Tech Mono', monospace;
	  font-size: 4vmin;
	  margin: 0;
	  height: 100vh;
	  width: 100%;
  }
`;

const StyledApp = styled.div`

`

const App = () => {
  return (
    <>
			<GlobalStyle />
			<StyledApp>
        <TimeDisplay
					className="count-down-timer"
					time={{
            hour: 0,
            min: 0,
            sec: 0
          }}
					delimiter=":"
					fontSize="2.8em"
				/>
      </StyledApp>
    </>
  )
}

export default App

タイムリミットの初期状態は「00:00:00」とします。

TimeDisplayコンポーネントは、タイムリミットを表示する役割を持ちます。TimeDisplayタイムリミットについては【React】時刻や生年月日などをセレクトボックスで選択するピッカーを作るをご覧ください。

ブラウザには次のようにタイムリミットが表示されます。

count-down-timer3

タイムリミットをタイムピッカーで選択する

タイムピッカーを表示し、ユーザーがそのタイムピッカーで選択した値がそのままタイムリミットの時・分・秒に反映されるようにします。

App.js
// 省略

import Spacer from './components/Spacer'
import ItemPickers from './components/ItemPickers'

const TIMES = {
	hour: [...Array(60)].map((u, i) => i),
	min: [...Array(60)].map((u, i) => i),
	sec: [...Array(60)].map((u, i) => i),
}

const GlobalStyle = createGlobalStyle`
  body {
  	// 省略
  }

  select, option {
  	font-family: 'Share Tech Mono', monospace;
  }
`;

// 省略

const App = () => {
  const [selectItems, setSelectItems] = useState(() => {
		const newItems = {}

		Object.keys(TIMES).forEach((name, i) => {
			newItems[name] = TIMES[name][0]
		})

		return newItems
	})

  const handleChange = useCallback((e) => {
		setSelectItems(p => ({
			...p,
			[e.target.id]: Number(e.target.value)
		}))
	}, [])

  // タイムリミットを保存するステート
  const [timeLimit, setTimeLimit] = useState(selectItems);

  // 値をゼロ埋めする関数
  const zeroPaddingNum = useCallback((num) => {
		return String(num).padStart(2, "0")
	}, [])

  // ピッカーで選択した値をそのままタイムリミットとして反映する
	useEffect(() => {
		setTimeLimit({
			hour: zeroPaddingNum(selectItems.hour),
			min: zeroPaddingNum(selectItems.min),
			sec: zeroPaddingNum(selectItems.sec)
		})
	}, [selectItems])

  return (
    <>
			<GlobalStyle />
			<StyledApp>
        <ItemPickers
					className="time-picker"
					items={TIMES}
					handleChange={handleChange}
				/>
				<Spacer size="1.6em" />
        <TimeDisplay
					// 省略

					time={timeLimit}
				/>        
      </StyledApp>
    </>
  )
}

export default App

ItemPickersコンポーネントについては【React】時刻や生年月日などをセレクトボックスで選択するピッカーを作るをご覧ください。

新たにtimeLimitステートを定義し、このステートでタイムリミットを管理します。初期値は、ピッカーで選択した値を保存しているselectItemsステートです。

ピッカーで選択した値は、ゼロ埋めしてからtimeLimitステートに上書きします。

useEffect(() => {
		setTimeLimit({
			hour: zeroPaddingNum(selectItems.hour),
			min: zeroPaddingNum(selectItems.min),
			sec: zeroPaddingNum(selectItems.sec)
		})
	}, [selectItems])

このtimeLimitステートをTimeDisplayコンポーネントのtimeプロパティに設定することで、次のようにピッカーで選択した値がそのままタイムリミットに反映されるようになります。

count-down-timer2

カウントダウンを開始する

スタートボタンを押すとカウントダウンが開始されるようにします。

App.js
// 省略

import Button from './components/Button'

const App = () => {
  // 省略

  // カウントダウンタイマーがスタートしているかどうか
	const [isStart, setIsStart] = useState(false)

  // setIntervalメソッドが返す一意に識別するIDを保存するRef
	const intervalID = useRef(null);

  // タイマーをスタートする関数
  const startTime = useCallback(() => {
		intervalID.current = setInterval(() => tick(), 1000);

    setIsStart(true)
  })

  // 時を刻む関数
  const tick = useCallback(() => {
		setTimeLimit((prevTimeLimit) => {
			const newTimeLimit = Object.assign({}, prevTimeLimit);
			const { hour, min, sec } = newTimeLimit;

      // 「時」が1時間以上あるときに「分」と「秒」が0以下になったら、「時」を1つ減らして、「分」を60に戻す
			if (newTimeLimit.hour > 0 && min <= 0 && sec <= 0) {
				newTimeLimit.hour -= 1;
				newTimeLimit.min = 60;
			}

      // 「分」が1分以上あるときに「秒」が0以下になったら、「分」を1つ減らして、「秒」を60に戻す
			if (newTimeLimit.min > 0 && newTimeLimit.sec <= 0) {
				newTimeLimit.min -= 1;
				newTimeLimit.sec = 60;
			}

      // 「秒」を1つ減らす
			newTimeLimit.sec -= 1;

			return {
				hour: zeroPaddingNum(newTimeLimit.hour),
				min: zeroPaddingNum(newTimeLimit.min),
				sec: zeroPaddingNum(newTimeLimit.sec)
			};
		});
	})

  return (
    <>
			<GlobalStyle />
			<StyledApp>
        {/* 省略 */}

        <TimeDisplay
					// 省略
				/>
        <Spacer size="1.6em" />
					<Button
						className="start-button"
						onClick={startTime}
						disabled={isStart ? true : false}
					>
						start
					</Button>
      </StyledApp>
    </>
  )
}

export default App

※Buttonコンポーネントについては【React】ボタンを作るをご覧ください。

カウントダウンは、startTime関数が実行されると行われます。

startTime関数が実行されると、setIntervalメソッドで1秒毎にtick関数を実行させ、その中でtimeLimitステートを更新させてカウントダウンさせます。

このstartTime関数をButtonコンポーネントのonClick属性に設定すると、スタートボタンが押されるとカウントダウンが開始されるようになります。

また、disabled属性を

<Button
	// 省略

  disabled={isStart ? true : false}
>

のようにすることで、isStartがtrueのとき、つまりカウントダウンが開始されているときはボタンを押しても反応させないようにします。反対に、falseであればカウントダウン前ということなので反応させるようにします。

カウントダウンの仕組みについてですが、時間、分、秒を1つ減らすタイミングは次の通りです。

時刻 1減らすタイミング
時間 1時間以上あるときに、分と秒が0以下になったとき
1分以上あるときに、秒が0以下になったとき
タイムアップまで常時

また、分と秒は次の条件に当てはまる間は、「00」になったあとに「60」に戻す必要があります。

時刻 条件
1時間以上ある間
1分以上ある間

カウントダウンを一時停止する

ストップボタンを表示して、カウントダウン中にストップボタンを押すとカウントダウンが一時停止されるようにします。

App.js
// 省略

const App = () => {
  // 省略

  // カウントダウンがストップしていればtrue
  const [isStop, setIsStop] = useState(false)

  const startTime = useCallback(() => {
	  // 省略

    setIsStop(false)
  })

  // カウントダウンを一時停止する関数
  const stopTime = useCallback(() => {
		clearInterval(intervalID.current);

    setIsStop(true)
    setIsStart(false)
	});

  // 省略

  return (
    <>
			<GlobalStyle />
			<StyledApp>
        {/* 省略 */}

				<div className="buttons">
          <Button
						className="start-button"
						// 省略
					>
						start
					</Button>
          <Spacer
						size=".65em"
						horizontal={true}
					/>
					<Button
            className="stop-button"
            onClick={stopTime}
						disabled={isStart ? false : true}
          >
						stop
					</Button>
        </div>
      </StyledApp>
    </>
  )
}

export default App

カウントダウンの一時停止はstopTime関数で行います。この関数は、clearIntervalメソッドでsetIntervalメソッドの実行を停止します。この際に必要となるのが先ほどのintervalIDなのです。

この関数をストップボタン.stop-buttonのonClick属性に設定することで、ボタンが押されたときにstopTime関数が実行されてカウントダウンが一時停止されるようになります。

00:00:00になったらタイムアップする

タイムリミットが「00:00:00」になったら、タイムアップということですので、まずカウントダウンを停止させます。

App.js
// 省略

const App = () => {
  // 省略

  // タイムアップしていればtrue
  const [isTimeUp, setIsTimeUp] = useState(false)

  const startTime = useCallback(() => {
	  // 省略

    setIsTimeUp(false)
  })

  // 省略

  const tick = useCallback(() => {
		setTimeLimit((prevTimeLimit) => {
			// 省略

      // カウントダウン中に00:00:00になったらタイムアップ
			if (hour <= 0 && min <= 0 && sec <= 0) {
				stopTime();

				setIsTimeUp(true)

				return newTimeLimit;
			}

      // 省略
		});
	})

  return (
    // 省略
  )
}

export default App

タイムアップした後はstopTime関数によってisStartステートがfalseに切り替わり、スタートボタンが再び有効になります。

再び有効になったスタートボタンを押したときは、ピッカーで選択した値で再びカウントダウンが始まるようにします。

App.js
// 省略

const App = () => {
  // 省略

  const tick = useCallback(() => {	
      // 省略
	})

  // タイムアップ後にスタートボタンを押したときは、選択したタイムからカウントダウンする
	useEffect(() => {

    // タイムアップ中且つスタートボタンを押した
		if(isStart && isTimeUp) {
			setTimeLimit({
				hour: zeroPaddingNum(selectItems.hour),
				min: zeroPaddingNum(selectItems.min),
				sec: zeroPaddingNum(selectItems.sec)
			})

			setIsTimeUp(false)
		}
	}, [isStart])// スタートボタンを押したときに実行

  return (
    // 省略
  )
}

export default App

isTimeUpステートはタイムアップしたかどうかを示し、このステートを利用してダイアログでタイムアップしたことを知らせるといったことができます。

タイムリミットをリセットする

リセットボタンを押すと、カウントダウン中や一時停止中に関わらずタイムリミットをピッカーで選択した値にリセットします。

App.js
// 省略

const App = () => {
  // 省略

  // カウントダウンがリセットされたらtrue
  const [isReset, setIsReset] = useState(false)

  const startTime = useCallback(() => {
	  // 省略

    setIsReset(false)
  })

  // タイムリミットをリセットする関数
  const resetTime = useCallback(() => {
		clearInterval(intervalID.current);

    // タイムピッカーで選択中の値に戻す
		setTimeLimit({
			hour: zeroPaddingNum(selectItems.hour),
			min: zeroPaddingNum(selectItems.min),
			sec: zeroPaddingNum(selectItems.sec)
		})

    setIsReset(true)
    setIsStart(false)
    setIsStop(false)
    setIsTimeUp(false)
	})

  // 省略

  return (
    <>
			<GlobalStyle />
			<StyledApp>
        {/* 省略 */}

				<div className="buttons">
          {/* 省略 */}

          <Spacer
						size=".65em"
						horizontal={true}
					/>
					<Button
						className="reset-button"
						onClick={resetTime}
						disabled={isStart || isStop && !isTimeUp && !isReset ? false : true}
					>
						reset
					</Button>
        </div>
      </StyledApp>
    </>
  )
}

export default App

タイムリミットのリセットはresetTime関数で行います。resetTime関数が実行されると、clearIntervalメソッドでsetIntervalメソッドの実行を停止し、タイムリミットをピッカーで選択したときの値に戻します。これがリセットということです。

このresetTime関数をリセットボタン.reset-buttonのonClick属性に設定することで、ボタンが押されるとresetTime関数が実行されてタイムリミットがリセットされます。

また、リセットボタンはdisabled属性を

<Button
  // 省略

  disabled={
    isStart ||
    isStop &&
    !isTimeUp && 
    !isReset ? false : true
  }
>

のように設定することで、

  • カウントダウン中または一時停止中
  • リセットしていない

ときのみリセットボタンを押せるようにしています。

ロジックとビューを分離する

ロジックで肥大化したAppコンポーネントのロジックとビューを分離します。

はじめに、Appコンポーネントからロジックを抽出し、それを内容とするカスタムフックを作成します。

hooks/useCountDownTimer.js
impot { useState, useRef, useCallback, useEffect } from 'react'

const useCountDownTimer = (time) => {
	const [timeLimit, setTimeLimit] = useState(time);

	const [isStart, setIsStart] = useState(false)
	const [isStop, setIsStop] = useState(false)
	const [isReset, setIsReset] = useState(false)
	const [isTimeUp, setIsTimeUp] = useState(false)

	const intervalID = useRef(null);

	const zeroPaddingNum = useCallback((num) => {
		return String(num).padStart(2, "0")
	}, [])

  const startTime = useCallback(() => {
		intervalID.current = setInterval(() => tick(), 1000);

		setIsStart(true)
    setIsStop(false)
    setIsTimeUp(false)
    setIsReset(false)
	});

	const stopTime = useCallback(() => {
		clearInterval(intervalID.current);

		setIsStop(true)
    setIsStart(false)
	});

	const resetTime = useCallback(() => {
		clearInterval(intervalID.current);

		setTimeLimit({
			hour: zeroPaddingNum(time.hour),
			min: zeroPaddingNum(time.min),
			sec: zeroPaddingNum(time.sec)
		})

		setIsReset(true)
    setIsStart(false)
    setIsStop(false)
    setIsTimeUp(false)
	})

	const tick = useCallback(() => {
		setTimeLimit((prevTimeLimit) => {
			const newTimeLimit = Object.assign({}, prevTimeLimit);
			const { hour, min, sec } = newTimeLimit;

			if (hour <= 0 && min <= 0 && sec <= 0) {
				stopTime();

				setIsTimeUp(true)

				return newTimeLimit;
			}

			if (newTimeLimit.hour > 0 && min <= 0 && sec <= 0) {
				newTimeLimit.hour -= 1;
				newTimeLimit.min = 60;
			}

			if (newTimeLimit.min > 0 && newTimeLimit.sec <= 0) {
				newTimeLimit.min -= 1;
				newTimeLimit.sec = 60;
			}

			newTimeLimit.sec -= 1;

			return {
				hour: zeroPaddingNum(newTimeLimit.hour),
				min: zeroPaddingNum(newTimeLimit.min),
				sec: zeroPaddingNum(newTimeLimit.sec)
			};
		});
	})

	// ピッカーで選択した値をそのままタイムリミットとして反映する
	useEffect(() => {
		setTimeLimit({
			hour: zeroPaddingNum(time.hour),
			min: zeroPaddingNum(time.min),
			sec: zeroPaddingNum(time.sec)
		})
	}, [time])

	// タイムアップした後にスタートボタンを押したときに選択したタイムからカウントダウンする
	useEffect(() => {
		if(isStart && isTimeUp) {
			setTimeLimit({
				hour: zeroPaddingNum(time.hour),
				min: zeroPaddingNum(time.min),
				sec: zeroPaddingNum(time.sec)
			})

			setIsTimeUp(false)
		}
	}, [isStart])

	return [
		timeLimit,
		startTime,
		stopTime,
		resetTime,
    { isStart, isStop, isTimeUp, isReset }
	];
};

export default useCountDownTimer

そして、作成したカスタムフックをAppコンポーネント内で呼び出します。

App.js
// 省略

import useCountDownTimer from './hooks/useCountDownTimer'

const App = () => {
  // 省略

  const [
		time,
		startTime,
		stopTime,
		resetTime,
		{ isStart, isStop, isTimeUp, isReset }
	] = useCountDownTimer(
		selectItems
	);

  return (
    // 省略
  )
}

export default App

これでAppコンポーネントの見通しが良くなりました。

DEMO

下記で実際の動作を確認できます。