目次
- はじめに
- 作ったもの
- 使用技術
- 画面とデータの設計
- localStorageへ保存・取得する
- React Routerで画面を切り替える
- Phaseでタイマー全体の状態を管理する
- useEffectとsetIntervalでカウントダウンする
- ラウンドとインターバルを制御する
- ランダムなタイミングで音を鳴らす
- new Audio()で効果音を再生する
- 秒数をmm:ss形式で表示する
- conic-gradientで円形プログレスを作る
- スマートフォン基準でレスポンシブ対応する
- 実装で詰まった点と学んだこと
- 今後改善したいこと
- まとめ
はじめに
React / TypeScriptの学習として、ランダムなタイミングで合図音を鳴らす「反応トレーニング」アプリを作成しました。
私は剣道とキックボクシングをしており、相手の動きに対して素早く反応するための練習を普段からおこなっています。その際、ドラゴンボールのミスターポポのいう、
「空のようにしずかに、雷よりもすばやく動く」
ような反応を目指して、次のようなトレーニングをスマートフォンだけで行えるアプリを作ることにしました。
いつ合図が来るかわからない状態で待ち、音が鳴った瞬間に動く
一般的なインターバルタイマーと異なり、このアプリの中心機能は「指定した範囲内から待ち時間を毎回ランダムに決めること」です。そのうえで、トレーニング時間、インターバル、ラウンド数も設定できるようにしました。
この記事では、完成したアプリの構成と、実装で特に学びが多かった次の内容を解説します。
-
localStorageから安全に設定を読み込む方法 - React Routerを使った一覧・編集・トレーニング画面の切り替え
-
phaseによるタイマー全体の状態管理 -
useEffectとsetIntervalを使ったカウントダウン -
Math.random()と再帰的なsetTimeoutを使ったランダム音 - CSSカスタムプロパティと
conic-gradientによる円形プログレス
コードは処理の要点が伝わる部分に絞って掲載しています。
作ったもの
ユーザーが設定したトレーニング時間の間、指定範囲内のランダムな間隔で合図音を鳴らすタイマーです。
設定できる項目は次のとおりです。
- タイマー名
- 合図音が鳴る最小間隔
- 合図音が鳴る最大間隔
- 1ラウンドのトレーニング時間
- ラウンド間のインターバル時間
- ラウンド数
たとえば、次のように設定します。
ランダム間隔:1〜3秒
トレーニング:3分
インターバル:1分
ラウンド数 :3回
トレーニングが始まると、3分間の中で1〜3秒おきにランダムで合図音が鳴ります。3分が経過すると1分間のインターバルへ移り、次のラウンドを開始します。
主な機能
- タイマー設定の新規作成
- タイマー設定の編集・削除
-
localStorageへの設定保存 - 開始前の3秒カウントダウン
- トレーニング時間とインターバル時間のカウントダウン
- 指定範囲内のランダムなタイミングで合図音を再生
- 複数ラウンドの実行
- カウントダウン音・開始音・終了音の再生
- 全ラウンド終了後の再実行
- 円形プログレスによる残り時間表示
- スマートフォンとPCのレスポンシブ対応
使用技術
| 技術 | 用途 |
|---|---|
| React | UIの構築とstate管理 |
| TypeScript | タイマー設定の型定義と型チェック |
| React Router | 画面遷移とURLパラメーターの取得 |
| localStorage | タイマー設定の永続化 |
Audio(HTMLAudioElement) |
効果音の再生 |
| CSS | レイアウト、円形プログレス、レスポンシブ対応 |
| Lucide React | 開始・編集・削除ボタンのアイコン |
音声再生にはWeb Audio APIのAudioContextではなく、ブラウザ標準のnew Audio()を使用しています。
画面とデータの設計
画面構成
画面は大きく3つに分けました。
Home
├─ 保存済みタイマーの一覧
├─ 新規作成画面への移動
├─ 編集画面への移動
└─ トレーニング画面への移動
TimerForm
├─ 新規作成
└─ 編集
Training
├─ 開始前カウントダウン
├─ トレーニング
├─ インターバル
└─ 終了
新規作成と編集では入力項目が同じなので、別々のコンポーネントを作らず、1つのTimerFormを共用しています。
ルーティング
URLは次の構成です。
| パス | 画面 |
|---|---|
/ |
タイマー一覧 |
/create |
タイマーの新規作成 |
/edit/:id |
指定したタイマーの編集 |
/training/:id |
指定したタイマーでトレーニング |
Timer型
タイマー設定はTimer型として定義しました。
export type Timer = {
id: string;
name: string;
randomMin: number;
randomMax: number;
trainingTime: number;
interval: number;
rounds: number;
};
| プロパティ | 内容 |
|---|---|
id |
タイマーを識別するID |
name |
タイマー名 |
randomMin |
合図音が鳴る最小間隔(秒) |
randomMax |
合図音が鳴る最大間隔(秒) |
trainingTime |
1ラウンドのトレーニング時間(秒) |
interval |
ラウンド間の休憩時間(秒) |
rounds |
実行するラウンド数 |
時間は内部ですべて「秒」として保持しています。たとえば3分は180です。
分と秒を別々の値で持つと、減算やリセットのたびに繰り上がり・繰り下がりを考える必要があります。秒に統一すれば、カウント処理は単純に1ずつ減らすだけです。画面へ表示するときにだけ03:00へ変換します。
localStorageへ保存・取得する
保存時は配列をJSON文字列に変換する
localStorageへ保存できるのは文字列です。そのため、Timer[]をJSON.stringify()で文字列に変換します。
export function saveTimers(timers: Timer[]): void {
localStorage.setItem('timers', JSON.stringify(timers));
}
新しいタイマーを作るときは、保存済みの配列へ追加してから配列全体を保存します。
const savedTimers = loadTimers([]);
const newTimer: Timer = {
id: crypto.randomUUID(),
name: trimmedName,
randomMin,
randomMax,
trainingTime,
interval,
rounds,
};
saveTimers([...savedTimers, newTimer]);
newTimerだけを保存すると、2件目を作成したときに1件目が消えてしまいます。常にTimer[]として扱うのがポイントです。
JSON.parseの結果をそのまま信用しない
JSON.parse()の結果は、実行時にどのような値が入っているかわかりません。TypeScriptでTimer[]と書いても、ブラウザに保存された実データまで自動で検証されるわけではありません。
そこで、パース結果を一度unknownとして受け取り、型ガードで確認してから返すようにしました。
import type { Timer } from '../types/timer';
function isTimer(value: unknown): value is Timer {
if (typeof value !== 'object' || value === null) {
return false;
}
const timer = value as Record<string, unknown>;
return (
typeof timer.id === 'string' &&
typeof timer.name === 'string' &&
typeof timer.randomMin === 'number' &&
typeof timer.randomMax === 'number' &&
typeof timer.trainingTime === 'number' &&
typeof timer.interval === 'number' &&
typeof timer.rounds === 'number'
);
}
export function loadTimers(fallback: Timer[]): Timer[] {
const savedTimers = localStorage.getItem('timers');
if (!savedTimers) {
return fallback;
}
try {
const parsed: unknown = JSON.parse(savedTimers);
if (Array.isArray(parsed) && parsed.every(isTimer)) {
return parsed;
}
return fallback;
} catch {
return fallback;
}
}
ここでは2段階で確認しています。
-
Array.isArray(parsed)で配列かを確認する -
parsed.every(isTimer)ですべての要素がTimerかを確認する
保存データが存在しない場合、形式が不正な場合、JSONの解析に失敗した場合は、引数で受け取ったfallbackを返します。
IDを使った単体取得と削除
編集画面とトレーニング画面では、URLに含まれるIDから対象のタイマーを1件取得します。
export function loadTimer(id: string): Timer | undefined {
const timers = loadTimers([]);
return timers.find((timer) => timer.id === id);
}
削除では対象ID以外を残した新しい配列を作り、保存し直します。
export function deleteTimer(id: string): Timer[] {
const timers = loadTimers([]);
const updatedTimers = timers.filter((timer) => timer.id !== id);
saveTimers(updatedTimers);
return updatedTimers;
}
削除後の配列を返しているのは、Home側のstateも同じ内容へ更新するためです。
function handleDelete(id: string) {
const updatedTimers = deleteTimer(id);
setTimers(updatedTimers);
}
localStorageの内容だけを変更しても、Reactの画面は自動では更新されません。永続化したデータと画面用のstateを両方更新する必要があります。
新規作成と編集でフォームを共用する
フォームではURLにIDがあるかどうかで、新規作成と編集を分けました。
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) {
setError('タイマー名を入力してください');
return;
}
if (
randomMin <= 0 ||
randomMax <= 0 ||
trainingTime <= 0 ||
interval <= 0 ||
rounds <= 0
) {
setError('時間・回数は1以上を入力してください');
return;
}
const savedTimers = loadTimers([]);
let updatedTimers: Timer[];
if (id) {
updatedTimers = savedTimers.map((timer) =>
timer.id === id
? {
...timer,
name: trimmedName,
randomMin,
randomMax,
trainingTime,
interval,
rounds,
}
: timer,
);
} else {
const newTimer: Timer = {
id: crypto.randomUUID(),
name: trimmedName,
randomMin,
randomMax,
trainingTime,
interval,
rounds,
};
updatedTimers = [...savedTimers, newTimer];
}
saveTimers(updatedTimers);
navigate('/');
}
編集時はmap()で全件を走査し、IDが一致した要素だけを新しい内容へ置き換えています。元の配列を直接変更せず、新しい配列を作る形です。
React Routerで画面を切り替える
ルートはApp.tsxへまとめました。
import { Route, Routes } from 'react-router-dom';
function App() {
return (
<main className="timer-app">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/create" element={<TimerForm />} />
<Route path="/edit/:id" element={<TimerForm />} />
<Route path="/training/:id" element={<Training />} />
</Routes>
</main>
);
}
一覧画面ではuseNavigate()を使い、対象のIDを含むURLへ移動します。
const navigate = useNavigate();
<button onClick={() => navigate(`/training/${timer.id}`)}>
開始
</button>
<button onClick={() => navigate(`/edit/${timer.id}`)}>
編集
</button>
遷移先ではuseParams()でIDを受け取り、保存済みデータを取得します。
const { id } = useParams();
const timer = id ? loadTimer(id) : undefined;
存在しないIDが指定される可能性もあるため、取得結果はTimer | undefinedです。該当データがなければ「タイマーが見つかりません」と表示します。
Phaseでタイマー全体の状態を管理する
このアプリには、開始前・トレーニング中・インターバル中・終了後という複数の状態があります。残り秒数だけで現在の状態を推測すると条件分岐が複雑になるため、現在の状態をphaseとして明示しました。
type Phase = 'countdown' | 'training' | 'interval' | 'finished';
const [phase, setPhase] = useState<Phase>('countdown');
状態遷移は次のとおりです。
countdown
↓
training
↓
最終ラウンドではない ─→ interval ─→ 次のtraining
│
└ 最終ラウンド ─────→ finished
表示もphaseで切り替えます。
{phase === 'countdown' && <CountdownView />}
{phase === 'training' && <TrainingView />}
{phase === 'interval' && <IntervalView />}
{phase === 'finished' && <FinishedView />}
実際のコードでは表示を同じコンポーネント内に記述していますが、考え方としては「phaseが現在表示する画面と動かす処理を決める」という構造です。
useEffectとsetIntervalでカウントダウンする
タイマーに必要なstate
Trainingでは、フェーズに加えて各残り時間と現在のラウンドをstateで管理します。
const [startCountDown, setStartCountDown] = useState(3);
const [trainingTime, setTrainingTime] = useState(
timer?.trainingTime ?? 0,
);
const [intervalTime, setIntervalTime] = useState(
timer?.interval ?? 0,
);
const [currentRound, setCurrentRound] = useState(1);
開始前の3秒カウントダウン
phaseがcountdownの間だけ、1秒ごとに値を減らします。
useEffect(() => {
if (phase !== 'countdown') {
return;
}
const timerId = window.setInterval(() => {
setStartCountDown((current) => {
if (current > 1) {
return current - 1;
}
window.clearInterval(timerId);
return 0;
});
}, 1000);
return () => window.clearInterval(timerId);
}, [phase]);
useEffectの最後でクリーンアップ関数を返しています。画面を離れたときや依存値が変わったときに、不要になったsetIntervalを確実に停止するためです。
カウントを減らす処理と、0になった後の状態遷移は分けました。
useEffect(() => {
if (phase !== 'countdown' || startCountDown !== 0) {
return;
}
setPhase('training');
trainingStartAudio.play();
}, [phase, startCountDown]);
トレーニング時間を減らす
トレーニング中も基本構造は同じです。
useEffect(() => {
if (phase !== 'training') {
return;
}
const timerId = window.setInterval(() => {
setTrainingTime((current) => {
if (current > 1) {
return current - 1;
}
window.clearInterval(timerId);
return 0;
});
}, 1000);
return () => window.clearInterval(timerId);
}, [phase]);
このuseEffectの役割は、あくまでtrainingTimeを減らして0にすることだけです。次のフェーズへの切り替えは別のuseEffectで行います。
useEffect(() => {
if (phase !== 'training' || trainingTime !== 0 || !timer) {
return;
}
trainingEndAudio.play();
if (currentRound < timer.rounds) {
setPhase('interval');
} else {
setPhase('finished');
}
}, [phase, trainingTime, currentRound, timer]);
trainingTimeを依存配列へ含めるのが重要です。phaseだけでは、trainingTimeが1から0になった変化を検知できません。
ラウンドとインターバルを制御する
phaseがintervalになったら、intervalTimeを1秒ずつ減らします。
useEffect(() => {
if (phase !== 'interval') {
return;
}
const timerId = window.setInterval(() => {
setIntervalTime((current) => {
if (current > 0) {
return current - 1;
}
return 0;
});
}, 1000);
return () => window.clearInterval(timerId);
}, [phase]);
0になったら、次のラウンド用にstateを戻してtrainingへ移ります。
useEffect(() => {
if (phase !== 'interval' || intervalTime !== 0 || !timer) {
return;
}
setCurrentRound((current) => current + 1);
setTrainingTime(timer.trainingTime);
setIntervalTime(timer.interval);
setPhase('training');
trainingStartAudio.play();
}, [phase, intervalTime, timer]);
最終ラウンドかどうかは、トレーニング終了時に判定済みです。そのため、最終ラウンドの後に不要なインターバルは入りません。
全ラウンド終了後の「もう一回」では、関連するstateを初期値へ戻します。
function handleRetry() {
if (!timer) {
return;
}
setStartCountDown(3);
setTrainingTime(timer.trainingTime);
setIntervalTime(timer.interval);
setCurrentRound(1);
setPhase('countdown');
}
ページを再読み込みしなくても、stateの初期化だけで最初から再実行できます。
ランダムなタイミングで音を鳴らす
Math.randomで待ち時間を作る
Math.random()は0以上1未満の値を返します。最小値と最大値の範囲へ変換する式は次のとおりです。
const randomTime =
Math.random() * (timer.randomMax - timer.randomMin) +
timer.randomMin;
たとえば最小1秒、最大3秒なら、1.42秒や2.76秒のような値になります。整数へ丸めていないため、合図の間隔をより予測しにくくできます。
setTimeoutを鳴るたびに予約し直す
固定間隔で繰り返すsetIntervalでは、毎回異なる待ち時間を作りにくいため、setTimeoutを再帰的に予約しました。
const randomAudio = new Audio('/sounds/piro.mp3');
useEffect(() => {
if (phase !== 'training' || !timer) {
return;
}
let timeoutId: number;
const currentTimer = timer;
function playRandomSound() {
const randomTime =
Math.random() *
(currentTimer.randomMax - currentTimer.randomMin) +
currentTimer.randomMin;
timeoutId = window.setTimeout(() => {
randomAudio.play();
// 音が鳴った後に、次の待ち時間を改めて決める
playRandomSound();
}, randomTime * 1000);
}
playRandomSound();
return () => window.clearTimeout(timeoutId);
}, [phase]);
処理の流れは次のとおりです。
training開始
↓
待ち時間をランダムに決定
↓
setTimeoutで待機
↓
合図音を再生
↓
次の待ち時間をランダムに決定
↓
繰り返し
phaseがintervalまたはfinishedへ変わるとクリーンアップが実行され、予約中のsetTimeoutが解除されます。これがないと、トレーニング終了後に合図音が鳴る可能性があります。
最初は、残り時間を減らすsetIntervalの中で毎秒setTimeoutを作っていました。しかし、その方法では未実行の予約が何個も重なります。「音が鳴ったら次を1件だけ予約する」という構造へ分けたことで、意図したランダム間隔になりました。
new Audio()で効果音を再生する
今回の音声ファイルはpublic/soundsへ配置しました。
public/
└─ sounds/
├─ piro.mp3
├─ countdown.mp3
├─ start.mp3
└─ end.mp3
Viteのpublic配下は、ブラウザからは/を起点に参照します。パスへpublicは含めません。
const randomAudio = new Audio('/sounds/piro.mp3');
const countdownAudio = new Audio('/sounds/countdown.mp3');
const trainingStartAudio = new Audio('/sounds/start.mp3');
const trainingEndAudio = new Audio('/sounds/end.mp3');
開始前の3・2・1では、カウント値の変化を検知して音を鳴らします。
useEffect(() => {
if (phase !== 'countdown') {
return;
}
if (startCountDown > 0) {
countdownAudio.play();
}
}, [startCountDown, phase]);
この実装はnew Audio()が返すHTMLAudioElementのplay()を使ったものです。Web Audio APIのAudioContextやノード構成は使用していません。
なお、コンポーネント直下でnew Audio()すると再レンダリングのたびにオブジェクトが作られます。今回の完成版ではこのシンプルな構成を採用しましたが、今後の改善としてuseRefで各音声オブジェクトを保持する余地があります。
秒数をmm:ss形式で表示する
内部では秒数で持ち、表示時にformatTime()でmm:ssへ変換します。
export function formatTime(time: number | undefined): string {
if (time === undefined) {
return '00:00';
}
const minutes = Math.floor(time / 60);
const seconds = time % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
たとえば138秒なら、次の計算になります。
Math.floor(138 / 60) = 2
138 % 60 = 18
結果:02:18
残り時間のstateを渡すことで、表示もカウントに合わせて変化します。
<p>{formatTime(trainingTime)}</p>
設定値のtimer.trainingTimeを渡すと、元の時間が表示され続けます。カウントダウン表示には、実際に減っているtrainingTimeを渡す必要があります。
conic-gradientで円形プログレスを作る
残り時間から進捗率を計算する
円形ゲージに使う割合は、残り時間を設定時間で割って求めます。
let progress = 0;
if (phase === 'training') {
progress = (trainingTime / timer.trainingTime) * 100;
} else if (phase === 'interval') {
progress = (intervalTime / timer.interval) * 100;
}
開始時は100%、半分経過すると50%、終了時は0%です。
Reactの値をCSSカスタムプロパティへ渡す
計算したprogressを、インラインスタイルからCSSカスタムプロパティ--progressへ渡します。
<div
className="training__countArea"
style={
{ '--progress': `${progress}%` } as React.CSSProperties
}
>
<p className="training__count training__count--small">
{formatTime(trainingTime)}
</p>
<span className="training__count training__count--bottom">
/{formatTime(timer.trainingTime)}
</span>
</div>
ブラウザ上では、たとえば次のようなstyleとして扱われます。
<div style="--progress: 75%;"></div>
conic-gradientでリングを描く
CSS側ではvar(--progress)を使い、進捗部分と残り部分を塗り分けます。
.training__countArea {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 200px;
height: 200px;
margin: 0 auto;
border-radius: 50%;
background: conic-gradient(
#2563eb 0% var(--progress),
#d1d5db var(--progress) 100%
);
}
.training__countArea::before {
content: '';
position: absolute;
inset: 15px;
border-radius: 50%;
background: #fff;
}
.training__count {
position: relative;
z-index: 1;
}
conic-gradientだけでは円盤になります。そこで、::beforeで一回り小さい白い円を重ね、中央を隠してリング状にしています。時間の文字にはz-index: 1を指定し、白い円より前面へ表示します。
スマートフォン基準でレスポンシブ対応する
このアプリはトレーニング中にスマートフォンで使うことを想定しているため、まず小さい画面向けのスタイルを書き、そのあとに768px以上でPC向けの幅を追加しました。
* {
box-sizing: border-box;
}
main {
min-height: 100vh;
background-color: #f8fafc;
}
.home,
.training {
width: 100%;
padding: 20px;
}
.card,
.timerForm__form {
width: 100%;
}
@media (min-width: 768px) {
.home,
.training {
max-width: 720px;
margin: 0 auto;
}
.timerForm__form {
max-width: 560px;
margin: 0 auto;
}
}
スマートフォンでは画面幅を無駄なく使い、PCではコンテンツが横に広がりすぎないようmax-widthで制限します。
また、タッチ端末には実質的なhoverがないため、hover表現はマウス操作ができる環境だけに限定しました。
@media (hover: hover) {
.btn:hover {
cursor: pointer;
filter: brightness(90%);
transform: translateY(-2px);
}
}
box-sizing: border-boxも重要でした。これを指定すると、width: 100%へpaddingやborderを含めて計算できるため、入力欄が親要素からはみ出すのを防げます。
実装で詰まった点と学んだこと
1. state更新関数の中で後続処理まで進めない
最初は、残り時間を更新する関数の中でsetPhase()など別のstateも変更していました。
// 避けた書き方
setTrainingTime((current) => {
if (current > 1) {
return current - 1;
}
setPhase('interval');
return 0;
});
stateの更新関数では、現在値から次のカウント値を求めることへ集中させます。今回はsetPhase()や音声再生などの後続処理を別のuseEffectへ移し、次のように責務を分けました。
useEffect A:残り時間を減らして0にする
↓
useEffect B:0になったことを検知してphaseを変える
この分離によって、タイマーの進行と状態遷移を追いやすくなりました。
2. 依存配列は「何が変わったとき再判定したいか」で決める
trainingTimeが0になったら次のフェーズへ進む処理では、phaseだけでなくtrainingTimeも依存配列に必要です。
useEffect(() => {
// trainingTimeが0かを判定する処理
}, [phase, trainingTime]);
phaseはトレーニング中ずっとtrainingのままです。trainingTimeを含めなければ、残り時間が0になったときに再判定されません。
3. useEffectは条件分岐の中で呼ばない
phaseごとに処理を分けようとして、switchの各case内でuseEffectを呼びたくなりました。しかしHooksは、コンポーネントのトップレベルで毎回同じ順番に呼ぶ必要があります。
そのため、各useEffectはトップレベルに置き、内部で対象のphaseかを判定しました。
useEffect(() => {
if (phase !== 'training') {
return;
}
// training中の処理
}, [phase]);
4. setIntervalとsetTimeoutの役割を分ける
今回のアプリでは、2種類の時間処理があります。
setInterval
└─ 1秒ごとに残り時間を減らす
setTimeout
└─ ランダムな時間だけ待って合図音を1回鳴らす
両方を同じ処理へ詰め込むと、ランダム音の予約が重複しました。用途ごとにuseEffectを分けることで整理できました。
5. 非同期処理はクリーンアップする
setIntervalとsetTimeoutは、作成したら対応する停止処理が必要です。
return () => window.clearInterval(timerId);
return () => window.clearTimeout(timeoutId);
画面遷移やフェーズ変更後も古い処理が動き続ける問題を防げます。
6. 外部データはunknownから確認する
TypeScriptの型はコンパイル時の仕組みであり、localStorageに実際に入っている値を保証しません。
const parsed: unknown = JSON.parse(savedTimers);
一度unknownとして受け取り、Array.isArray()と型ガードで確認してからTimer[]として扱うことで、安全な読み込み処理にできました。
7. numberとNumberは別物
型定義で使うのは小文字のnumberです。
randomMin: number;
一方、inputの文字列を数値へ変換するときは、大文字のNumber()を関数として使います。
setRandomMin(Number(event.target.value));
number :TypeScriptの数値型
Number() :値を数値へ変換する関数
見た目が似ていますが、役割は異なります。
8. 保存データとReactのstateは別々に更新する
localStorageからデータを削除しても、表示中の配列は自動では変わりません。永続化処理の後にsetTimers()も実行し、画面を再レンダリングする必要がありました。
この経験から、次の責務を分けて考えられるようになりました。
timerStorage.ts:データの保存・取得・削除
Home.tsx :画面へ表示するstateの管理
TimerList.tsx :一覧表示とユーザー操作
今後改善したいこと
初版に必要な機能は実装できましたが、次の改善余地があります。
-
new Audio()で作る音声オブジェクトをuseRefで保持する - 一時停止・再開機能を追加する
- 用途別のプリセットを用意する
- 合図音を選択できるようにする
- 実施日、時間、ラウンド数などのトレーニング記録を残す
- PWA化してホーム画面から起動できるようにする
- 不正なURLや壊れた保存データに対するエラー表示を改善する
- ランダム最小値が最大値を超えないよう、入力チェックを強化する
機能を増やす場合も、phaseを状態管理の軸にすれば、pausedなどの状態を追加しやすいと考えています。
GitHub・デモ
GitHub
- GitHubはこちらです。
デモ
- 実際のデモ画面はこちらです。
まとめ
React / TypeScriptで、ランダムな合図に反応するためのトレーニングタイマーを作成しました。
今回の実装で特に重要だったのは、次の4点です。
-
countdown / training / interval / finishedをphaseで明示する - カウント処理とフェーズ遷移を別の
useEffectへ分ける - ランダム音は
setTimeoutを再帰的に予約し、終了時に解除する -
localStorageのデータはunknownとして受け取り、型ガードで検証する
単純なタイマーに見えても、実際には状態遷移、非同期処理、クリーンアップ、データ永続化、実行時の型チェックなど、React / TypeScriptの重要な要素が多く含まれていました。
特に、画面の状態をstateとして明示し、stateの変化を起点に次の処理へ進める設計は、今回のアプリ以外にも応用できる学びになりました。
