初めに
ども!今月はAIと一緒に開発を進めたり、作っているサービスのバグを片っ端から対応していた龍ちゃんです。今月はAIをテーマにブログを書いていたのですが、過去記事の整理をしていたら、1年半前に書いた記事が発掘されたので検証と執筆を行いました。
皆さんは、集中して作業する時にタイマーを使っていますか?ポモドーロテクニックなどで使う簡単なタイマーが欲しいなと思い、今回はReactとTypeScriptを使ってシンプルなタイマーアプリを実装してみました。
実装する機能
今回実装するのは、『タイマー機能』になります。要件としては、以下のようになります。
機能名 | 機能 |
---|---|
スタート | タイマーをスタートさせる 『スタート』を押下後、『ポーズ』と『リセット』が押されるまでは非アクティベート |
ストップ | タイマーをリセットさせる タイマーが止まっている場合は、非アクティベートする。『スタート』押下後、アクティベート |
ポーズ | タイマーをポーズさせる タイマーが止まっている場合は、非アクティベートする。『スタート』押下後、アクティベート |
時間設定 | タイマーの時間を設定する。選択肢は180s(3min)/300s(5min)/600s(10min)/1200s(20min) |
タイマー表示 | タイマー表示を行う。表示形式としては「00:00」を想定とする |
実装イメージは以下のようになります。

今回使用するフォントはこちらです。デザイン性を重視して、少し個性のあるフォントを選んでみました。
フォント名 | URL |
---|---|
DotGothic16 | https://fonts.google.com/specimen/DotGothic16 |
Zen Maru Gothic | https://fonts.google.com/specimen/Zen+Maru+Gothic |
コーディング
それでは、実装について詳しく見ていきましょう。今回の実装は、タイマー表示機構・ボタンコンポーネント・タイマー機構兼レイアウトの三つに分類されています。コンポーネント分離することで、責任を明確にして保守性を高める設計にしました。
段階に沿って実装のコードを載せていきますね。
タイマー表示機構
まずは、時間を表示するコンポーネントから実装していきます。
import { useMemo } from "react";
type TimerCounterProps = {
time: number;
};
export const TimerCounter = (props: TimerCounterProps) => {
const { time } = props;
// タイムオーバー判定:0以下になったらタイムオーバー
const isTimeOver = useMemo(() => time <= 0, [time]);
// 分の計算:タイムオーバー時は絶対値で表示
const minutes = useMemo(() => {
const temp = isTimeOver ? time * -1 : time;
const minute = Math.floor(temp / 60);
if (minute < 10) {
return `0${minute}`;
} else {
return `${minute}`;
}
}, [time, isTimeOver]);
// 秒の計算:60で割った余りを取得
const seconds = useMemo(() => {
const temp = isTimeOver ? time * -1 : time;
const second = Math.floor(temp % 60);
if (second < 10) {
return `0${second}`;
} else {
return `${second}`;
}
}, [time, isTimeOver]);
return (
<h1
className={
"flex grow items-center justify-center font-dela text-[240px] " +
(isTimeOver ? " text-red-500" : " text-black")
}
>
{minutes}:{seconds}
</h1>
);
};
このコンポーネントのポイントは、タイムオーバー時の処理ですね。0以下になった場合に赤色で表示するようにしています。また、分と秒の計算ではuseMemo
を使ってパフォーマンスを最適化しています。
ボタンコンポーネント
次に、再利用可能なボタンコンポーネントを作成します。まずはデザインスタイルの定数を定義しますね。
export const ButtonDesignStyle = {
default: " border border-gray text-gray",
blue: " border border-blue-500 text-blue-500",
fillBlue: " bg-blue-500 text-white",
red: " border border-red-400 text-error",
fillRed: " bg-red-400 text-white",
} as const satisfies Record<string, string>;
import { ButtonDesignStyle } from "../constants/ButtonDesignStyle";
type TimerButtonProps = {
label: string;
onClick: () => void;
color: keyof typeof ButtonDesignStyle;
disabled?: boolean;
};
export const TimerButton = (props: TimerButtonProps) => {
const { label, onClick, color, disabled } = props;
return (
<button
className={
ButtonDesignStyle[color] +
" inline-flex items-center justify-center min-w-[100px] h-12 rounded-md font-bold " +
(disabled ? " cursor-not-allowed opacity-50" : " cursor-pointer")
}
disabled={disabled}
onClick={onClick}
>
{label}
</button>
);
};
ボタンのデザインは変更できるように定数としてデザインを定義しています。こちらの実装に関しては、「React+Tailwindコンポーネントの引数(変数)で色を設定したいとき」で解説していますので、詳細はそちらをご覧ください。
TypeScriptの型安全性を活かして、keyof typeof ButtonDesignStyle
で色の選択肢を制限しているのがポイントですね。
タイマー機構・レイアウト
最後に、メインのタイマーロジックとレイアウトを実装します。
import { useEffect, useRef, useState } from "react";
import { TimerButton } from "./TimerButton";
import { TimerCounter } from "./TimerCounter";
export const Timer = () => {
const [timer, setTimer] = useState(10 * 60); // デフォルト10分
const [defaultValue, setDefaultValue] = useState(10 * 60);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// コンポーネントのアンマウント時にインターバルをクリア
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
// タイマースタート機能
const startTimer = () => {
if (!isRunning) {
setIsRunning(true);
intervalRef.current = setInterval(() => {
setTimer((value) => {
if (value <= 1) {
// タイマーが0に到達したら停止
setIsRunning(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
return 0;
}
return value - 1;
});
}, 1000);
}
};
// タイマー停止機能
const stopTimer = () => {
setIsRunning(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
// タイマーリセット機能
const resetTimer = () => {
setIsRunning(false);
setTimer(defaultValue);
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
// タイマー時間設定機能
const setTimerValue = (value: number) => {
setTimer(value);
setDefaultValue(value);
};
return (
<>
<TimerCounter time={timer} />
<div className="fixed bottom-0 left-0 flex w-full flex-row justify-between p-5">
<div className="flex flex-row gap-3">
<TimerButton color="fillBlue" label="start" onClick={startTimer} />
<TimerButton
color="fillRed"
label="pause"
onClick={stopTimer}
disabled={!isRunning}
/>
<TimerButton color="fillRed" label="reset" onClick={resetTimer} />
</div>
<div className="flex flex-row gap-3">
<TimerButton
color="blue"
label="3"
onClick={() => setTimerValue(3 * 60)}
/>
<TimerButton
color="blue"
label="5"
onClick={() => setTimerValue(5 * 60)}
/>
<TimerButton
color="blue"
label="10"
onClick={() => setTimerValue(10 * 60)}
/>
<TimerButton
color="blue"
label="20"
onClick={() => setTimerValue(20 * 60)}
/>
</div>
</div>
</>
);
};
このメインコンポーネントでは、useRef
を使ってインターバルの参照を管理しているのがポイントですね。setInterval
のIDを保持することで、適切にクリーンアップできるようになります。
また、状態管理ではtimer
(現在の時間)、defaultValue
(リセット時に戻る時間)、isRunning
(実行状態)を分けて管理しています。これにより、各機能を独立して制御できるようになりました。
まとめ
今回は、React + TypeScriptを使ってシンプルなタイマーアプリを実装してみました。実装のポイントをまとめると以下のような感じです。
設計のポイント
- コンポーネント分離による責任の明確化
- TypeScriptによる型安全性の確保
useMemo
を使ったパフォーマンス最適化useRef
を使った適切なクリーンアップ処理
学んだこと
- インターバル処理では必ずクリーンアップが重要
- 状態管理は目的別に分けることで保守性が向上
- Tailwindでの動的スタイリングのベストプラクティス
実際に使ってみると、シンプルながらも実用的なタイマーになりました。ポモドーロテクニックで集中したい時などに重宝しそうです。
皆さんも、ぜひこのコードを参考にして自分なりのタイマーアプリを作ってみてください!React Hooksの使い方や状態管理の練習にも最適だと思います。