前提
TypeScriptの利用部分は簡単な説明にとどめます🙇
Nextで作りましたが、react18以降であれば同じコードで動作すると思います。
完成品
機能要件
- 「スタート」ボタンを押すと、1秒ごとに秒数が増える
- 「ストップ」ボタンを押すと、カウントが停止する
- 「リセット」ボタンを押すと、秒数が0に戻る&停止状態になる
非機能要件
- ボタンの状態に応じて「スタート/ストップ」が切り替わる
- カウントは
setInterval
で実行されるが、重複せず常に1つのインスタンスのみで動作 - ステートや副作用の管理は
useEffect
とuseState
を活用
まずはコードの全体像
import { NextPage } from 'next';
import { useEffect, useState } from 'react';
import Button from '@/components/common/parts/Button';
const Page: NextPage = () => {
const [seconds, setTime] = useState(0);
const [isActive, setIsActive] = useState(false);
useEffect(() => {
// 初期状態は undefined、setInterval の戻り値で上書きする
let interval: NodeJS.Timeout | undefined = undefined;
if (isActive) {
// スタート時:1秒ごとにカウントアップ
interval = setInterval(() => {
setTime((prev) => prev + 1);
}, 1000);
} else if (!isActive && seconds !== 0) {
// 停止時:前回の setInterval を停止
clearInterval(interval);
}
// クリーンアップ関数:再実行やアンマウント時に前の interval を停止
return () => clearInterval(interval);
}, [isActive, seconds]);
const handleClickToggle = () => {
setIsActive((prev) => !prev);
};
const handleClickReset = () => {
setTime(0);
setIsActive(false);
};
return (
<div className="mx-auto max-w-4xl">
<div className="mt-10 flex justify-center gap-x-8">
<div>
<h3>時間: {seconds}</h3>
</div>
<div className="ml-10 flex gap-x-8">
<Button
variant="text"
label={isActive ? 'ストップ' : 'スタート'}
onClick={handleClickToggle}
/>
<Button variant="text" label="リセット" onClick={handleClickReset} />
</div>
</div>
</div>
);
};
export default Page;
簡単な説明
このコンポーネントは、「タイマーの状態(動作中 or 停止中)」と「経過秒数」という2つの状態を管理しています。ユーザー操作(スタート/ストップ/リセット)に応じて、タイマーの実行・停止・リセットを切り替える処理をuseEffect
内で制御しています。
ロジック詳細
-
isActive
の状態が変化するたびにuseEffect
が実行されます。 -
setInterval
は毎秒1回、カウントアップ処理を行います。 -
useEffect
のクリーンアップ関数(return () => clearInterval(...)
)を活用し、常に古いタイマーを解除してから新しいタイマーを開始することで、重複して複数のタイマーが走ることを防いでいます。 -
seconds
が変わることでuseEffect
は再実行されますが、そのたびに前回のタイマーが正しく停止されているため、ロジックは安全に保たれます。
細かい部分の解説
タイマーIDの型について
let interval: NodeJS.Timeout | undefined = undefined;
-
setInterval
の戻り値の型はNodeJS.Timeout
- 初期はタイマーが存在しないので、型として
undefined
を含める -
= undefined
と明示しているのは、初期状態を明確にするためです
条件分岐:ストップ処理
else if (!isActive && seconds !== 0) {
clearInterval(interval);
}
- 停止状態(
!isActive
)かつ、秒数が0でない場合だけclearInterval
を呼びます - これは「すでにタイマーが動いていた場合のみ、止める必要がある」からです
クリーンアップ関数のタイミング
return () => clearInterval(interval);
-
useEffect
が再実行される直前に必ず呼ばれます - 前の
setInterval
を明示的に解除することで、タイマーが多重に動作するのを防ぎます -
ポイント!!
このコードだと毎秒secondsが変わるため、毎秒useeffectが走り新しいintervalが作成されちゃうからintervalが重なってバグるのでは?と思う方もいるでしょう。・- その対策で再度useeffectが走る前にクリーンアップ関数で前のintervalを削除しています。少し凍のロジックがややこしいですね。
コメント・フィードバック歓迎
補足や疑問点、もっと良い書き方があればぜひコメントお願いします!