Webタイマーアプリを未経験採用から半年でReact・TypeScriptで作った話

## はじめに

未経験採用から半年の新人エンジニアである私が Web タイマーアプリを作成しました。

https://untimer.unreact.jp/?from=qiita

シンプルなデザインで使いやすいので、ぜひ触ってみてください。

未経験での採用から約半年、React について勉強させていただいたり、Liquid を使った Shopify アプリの開発を行なってきました。

そして今回は、Web アプリの開発をしてみようということで、React、Next.js、TypeScript を使ってタイマーアプリを制作することになりました！

## Web タイマーを開発することになった経緯

そもそも、なぜ Web タイマーを作ることになったのか、というところから書いていきます。

それは、デザインが良くて使いやすい Web タイマーアプリが見当たらなかったからです。

弊社では、毎週定例のハドルミーティングの冒頭で、Good & New と言う活動を行なっています。Good & New では、身近にあった Good かつ New な出来事を 1 人 1 分半という決まった時間で発表していきます。

その際に Web タイマーを使用するのですが、見た目が良く、機能も使いやすいものがなかなか見つかりませんでした。見た目がスタイリッシュなものは使いづらく、逆に使いやすいものは見た目が少し残念、といった具合です。なので毎週、「なかなか良い Web タイマーが無いねぇ」と言いながら Good & New を行なっていました。

そんな中、Web アプリを開発しよう、と言う新しい会社の方針が決定し、いろんなアプリ案が上がった中の一つがタイマーアプリでした。

そして、アプリ案の中で一番難易度が低そうなタイマーアプリの開発担当として、未経験採用・入社半年の私が選ばれたと言う運びです。

ちなみに、今回作ったタイマー機能を流用した Good & New 用の Web アプリもあるので、興味のある方は触ってみてください。

https://good-and-new.unreact.jp/?from=qiita

## タイマーの機能・使い方
次に、今回開発したタイマーアプリの機能と使い方について書いていきます。

といっても見ての通りですが、スタート・ストップ・リセット・クリアなどの基本操作ボタンと、時間設定用のボタンがあるのみです。

![スクリーンショット 2024-10-11 19.32.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3756381/7a8a3375-d847-2500-4ff9-150529a569b3.png)

時間設定は、時・分・秒の上下にある、それぞれ 1 ずつ増減するボタンと、まとめて時間を追加できるボタンがあります。これらをポチポチとクリックすることで任意の時間に設定できます。

実行中はこんな感じで、スタートボタンがストップボタンに、クリアボタンがリセットボタンに変わります。

![スクリーンショット 2024-10-11 19.31.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3756381/0e75f821-6076-16ae-893b-0eb5952050b5.png)

また、タイマーが終了すると、時間表示部が緩やかに点滅し、優しく時間の経過を教えてくれます。
既存の Web タイマーアプリの多くは、音を鳴らして終了を知らせてくれますが、正直耳に刺さるものが多い印象です。こちらのタイマーを利用すれば、より優雅なひと時を味わうことができるでしょう。

ただし、流石に優雅すぎて気づかないので、いずれ修正するかもしません。

ちなみにタイマーの下部には、Local Storage に保存されるメモ機能もついてます。

![スクリーンショット 2024-10-11 19.34.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3756381/5894a87e-4fe1-f348-84f6-3234d207cdb6.png)

## 実装

次に、実装について書いていきます。

react-timer-hook などのライブラリを使わずに実装しました。理由は、あまり難しくなさそうだったことと、純粋に自作してみたいという好奇心からです。

加えて一つ注意点です。厳密に言うと、今回の実装だと正確な時間を測ることはできません。理由は、タイマーの正確性を setTimeout() に依存しているためです。
今回の実装では、単に setTimeout() で 1 秒後に残り時間を 1 秒減少させるという処理を繰り返しているだけなので、その処理にかかる時間分（数ミリ秒）徐々に遅れていきます。あくまで、今回タイマーアプリを作成する目的は、「見た目が良く、使いやすい」なので、そこは妥協するものとします。

### 各 state の説明

まずは、使用する state の説明から。

``` .ts
// タイマーの状態
// この値によって、タイマーを開始・停止・リセットする
const [timerState, setTimerState] = useState<TimerStateValue>(TimerStateValue.STAND_BY);

// 制限時間（ミリ秒）
// リセット時に残り時間がこの値になる
const [timeLimit, setTimeLimit] = useState(0);

// 残り時間（ミリ秒）
// スタート後に毎秒 1000 ずつ減っていく
const [remainingTime, setRemainingTime] = useState(0);

// タイマー ID（レンダリングには関係ないので useRef に格納）
const timerIdRef = useRef<NodeJS.Timeout | undefined>(undefined);
```

`timerState`
この値でタイマーアプリの状態を管理する
以下の値が入り、これによりタイマーを開始・停止・終了させる
- 停止（開始前）： `standBy`
- タイマー稼働中： `active`
- タイマー終了： `end`

`timeLimit`
タイマーの制限時間（ミリ秒）
リセットボタンが押されると、残り時間がこの値になる
クリアボタンが押されると、0 になる

`remainingTime`
タイマーの残り時間（ミリ秒）
この値を `"HH:MM:SS"` の形にして表示する
タイマー稼働中は毎秒 1000 ずつ減っていく
この値が 0 になるとタイマーを終了させる
リセットボタンが押されると制限時間の値になる

`timerIdRef`
setTimeout() から返されるタイマー ID を格納
レンダーには関係ないため、useState ではなく useRef を使う

とこんな感じです。

### カウントダウン機能の実装

次に、カウントダウン機能の実装について書いていきます。

``` .ts
// カウントダウン機能
// タイマーの状態や残り時間が更新されたら、カウントダウンを開始したり終了したりする
useEffect(() => {
// タイマー稼働中以外は何もしない
if (timerState !== TimerStateValue.ACTIVE) {
  return;
}
// setTimeout が被らないように clearTimeout する
clearTimeout(timerIdRef.current);
// 残り時間がまだあれば、１秒後にカウントダウン。タイマー ID も更新
if (remainingTime > 0) {
  timerIdRef.current = setTimeout(() => {
    setRemainingTime((prevState) => prevState - ONE_SECOND);
  }, ONE_SECOND);
} else {
  // 残り時間が 0 なら、タイマーを終了する
  setTimerState(TimerStateValue.END);
}
return () => clearTimeout(timerIdRef.current);
}, [remainingTime, timerState]);
```

こんな感じですね。

ざっくり説明します。

残り時間やタイマーの状態が変わるたびに、useEffect に渡したコールバック関数が実行されます。

この関数では、タイマーが実行中であれば 1 秒後に残り時間から 1 秒を引きます。すると残り時間の値が更新されるので、またこの関数が走り、そのまた 1 秒後に残り時間から 1 秒が引かれ...　と言う具合に毎秒処理が繰り返されます。

そして残り時間が 0 秒になると、タイマーを終了させます。

### 時間設定ボタンの関数

次に、時間を増減させる関数について書いていきます。
以下が設定時間を増加させる関数です。

``` .ts
// 時間追加ボタンのクリックイベントハンドラー
// ミリ秒を受け取り、制限時間と残り時間に足す
const handleClickPlus = useCallback(
(milliSeconds: number) => {
  // 最大値以上になる場合は足せない
  if (timeLimit + milliSeconds >= MAX_TIME) {
    return; // 何もしない
  }
  // 終了状態なら、タイマーをクリアして待機状態にしておく
  if (timerState === TimerStateValue.END) {
    clearAndStandByTimer();
  }
  // 制限時間と残り時間に時間を足す
  setTimeLimit((prev) => prev + milliSeconds);
  setRemainingTime((prev) => prev + milliSeconds);
},
[timeLimit, timerState, clearAndStandByTimer],
);
```

この関数はミリ秒を引数に取り、その値の分だけ、制限時間と残り時間を増加させます。

使い方としては、それぞれの時間追加ボタンの onClick に関数定義を書き、その中身でそのボタンに応じた値を渡して実行します。
たとえば、1 時間増加させるボタンの場合は以下のようになります。`ONE_HOUR` には 1 時間をミリ秒に換算した値が入っています。

``` .tsx
<PlusMinusButton
  plusOrMinus="plus"
  onClick={() => handleClickPlus(ONE_HOUR)}
  className="w-full md:w-auto"
/>
```

### 表示の処理

次に、残り時間を表示するための処理について書きます。

残り時間はミリ秒で管理しているので、表示する際に "HH:MM:SS" の形に変換する必要があります。以下のコードでその処理を行います。

``` .ts
// 数字を二桁の文字列にして返す（「1」を渡すと「"01"」を返す）
export const makeDoubleDigits = (num: number): string => {
  const doubleDigits = num < 10 ? '0' + num : '' + num;
  return doubleDigits;
};

// ミリ秒を "HH:MM:SS" の文字列にして返す
export const serializeTime = (milliSeconds: number): string => {
  const hh = makeDoubleDigits(Math.floor(milliSeconds / ONE_HOUR));
  const mm = makeDoubleDigits(Math.floor((milliSeconds % ONE_HOUR) / ONE_MINUTE));
  const ss = makeDoubleDigits(Math.floor((milliSeconds % ONE_MINUTE) / ONE_SECOND));
  return `${hh}:${mm}:${ss}`;
};
```

`serializeTime` に残り時間（ミリ秒）を渡すと、`hh`, `mm`, `ss` の部分をそれぞれ算出して、"HH:MM:SS" の文字列として返します。

以下のようにして使用します。


``` .tsx
<div className="font-mono text-[72px] font-bold leading-none">
  {serializeTime(remainingTime)}
</div>
```

実装については、以上のような感じです。

### 反省点

最後に、反省点を書いていきます。

今回の反省点は、簡単に言うと独りよがりな読みづらいコードを書いてしまっていたことです。

具体的には、以下の 2 点です。
- 会社のコーディングルールや慣習を無視していた
- 読み手への配慮に欠ける読みづらい書き方していた

1 点目の「会社のコーディングルールや慣習を無視していた」について

早期リターン時に `{}` を省略して 1 行で書いていたり、さらにその行にコメントまで記述したりしてました。以下のような感じですね。

``` .ts
if (timeLimit < milliSeconds) return; // 制限時間より大きい数字は引けない
if (remainingTime < milliSeconds) return; // 残り時間より大きい数字は引けない
if (timerState === TimerStateValue.END) return; // タイマーが終了済みの状態からは引けない
```

結果、コーディングルール・慣習に反していると注意を受けることとなります。
弊社のコーディングルール・慣習としては、以下が正しい書き方です。

``` .ts
// 制限時間より大きい数字は引けない
if (timeLimit < milliSeconds) {
  return; // 何もしない
} 
```

自分もそのような慣習があることは理解していました。しかし、より少ない行数で、スタイリッシュに書きたいと言う欲求から、1 行に全部詰め込んだような書き方をしてしまいました。

2 点目の「読み手への配慮に欠ける読みづらい書き方」について

具体的には、大して必要もないのに、あえて関数を返す関数を書いていて、無駄に読みにくいコードになっていました。

たとえば、時間設定ボタンの関数ではこのように書いていました。

``` .tsx
// milliSeconds を受け取って、その値分の時間を足す関数を返す関数
const handleClickPlus = (milliSeconds: number) => () => {
// 時間を足す処理...
};

return (
    {/* 1 時間追加するボタン */}
    <PlusMinusButton
      plusOrMinus="plus"
      onClick={handleClickPlus(ONE_HOUR)}
      className="w-full md:w-auto"
    />
)
```

このように書いた理由としては、`onClick` に渡す際の記述がすっきりして良いと思ったからです。

純粋に読みにくいとの注意を受け、以下のように修正しました。

``` tsx
// milliSeconds を受け取って、その値分の時間を足す
const handleClickPlus = (milliSeconds: number) => {
// 時間を足す処理...
};

return (
    {/* 1 時間追加するボタン */}
    <PlusMinusButton
      plusOrMinus="plus"
      onClick={() => handleClickPlus(ONE_HOUR)}
      className="w-full md:w-auto"
    />
)
```

確かにこの方が圧倒的にわかりやすいです。

このように、コードをスタイリッシュにしたい、より少ない行数で書きたい、テクニカルな書き方をしてみたい、と言うような独りよがりな書き方をしていたのが今回の反省点です。

リーダブルコードの 1 章目の第 1 項にあるような、典型的な読みづらいコードを書いてしまいました。

## 終わりに

今回、初めて Web アプリの開発でした。純粋にロジックを考えるのが楽しくて、時間の経過がとても早く感じました。

反省点を今後に活かして、読みやすい良いコードを書いていきたいです。

シンプルで使いやすい Web タイマーです。ぜひ使ってみてください。

https://untimer.unreact.jp/?from=qiita