はじめに
第1回では、「25:00」の表示と科目の入力欄を作った。
ただ、ボタンを押しても何も起きない、動かない画面だった。
第2回では、いよいよタイマーを動かす。useEffectとsetIntervalを使って、1秒ごとに残り時間を減らしていく。開始・一時停止・再開・リセットもできるようにする。
ただし、この方法にはずれるという弱点がある。最後に、それを実際に体験して確かめる。
今回やること
useEffectの基本的な使い方を知るsetIntervalで、1秒ごとに残り時間を減らす- クリーンアップ(後片付け)を書く
- 開始・一時停止・再開・リセットのボタンを作る
- 1秒ずつ減らす方式の「ずれ」を体験する
完成イメージ
開始すると、残り時間が1秒ずつ減っていく。

実行環境
第1回と同じである。(GitHub Codespaces)
ソースコードは、次の筆者のリポジトリの02_timerフォルダにある。
第1回の完成コードに、今回の変更を加えたものである。
cd 02_timer
npm install
npm run dev
今回の設計:stateは3つ
タイマーを動かすために、必要な情報を整理する。
| 情報 | 持ち方 |
|---|---|
| 科目 | subject(第1回から) |
| 残り時間(秒) | remainingSeconds(新規) |
| 動かしているかどうか | isRunning(新規) |
そして、次の3つは、stateにしない。
stateから計算できる値は、stateにしないのが、Reactの基本的な考え方である。
const isFinished = remainingSeconds === 0;
const isActive = isRunning && !isFinished; // 実際に動いている状態
const hasStarted = remainingSeconds < TOTAL_SECONDS;isFinished:残り時間が0になったかisActive:実際に動いているか。isRunningがtrueでも、0になっていれば動いていないhasStarted:一度でも始めたか(残り時間が、最初より減っているか)
stateを増やすと、「残り時間は0なのに、isFinishedはfalse」のような、食い違いが起きる可能性がある。計算で求めれば、食い違いは起きない。
useEffectとは
ここまでのコンポーネントは、「stateとpropsから、画面を作る」ことだけをしていた。一方、タイマーのように、画面を作ること以外の処理が必要な場面がある。
- 一定時間ごとに、何かを実行する(今回のタイマー)
- 外部のサーバーから、データを取得する
- ブラウザのタブのタイトルを書き換える
こうした処理を書く場所が、useEffectである。基本の形は、次のとおりである。
useEffect(() => {
// 実行したい処理
return () => {
// 後片付け(必要な時だけ)
};
}, [依存配列]);- 1つ目の引数:実行したい処理を書いた関数
- 戻り値の関数:後片付けの処理(クリーンアップ)
- 2つ目の引数:依存配列。ここに書いた値が変わった時に、処理を実行し直す
setIntervalで、1秒ごとに減らす
setIntervalは、一定時間(ミリ秒)ごとに、関数を繰り返し実行するJavaScriptの機能である。1000ミリ秒は1秒である。
今回のタイマーの部分は、次のとおりである。
useEffect(() => {
if (!isActive) return;
const id = setInterval(() => {
setRemainingSeconds((prev) => prev - 1);
}, 1000);
// 後片付け:タイマーを止める
return () => clearInterval(id);
}, [isActive]);順に見ていこう。
動いている間だけ動かす
if (!isActive) return;動いていない時は、何もしない。returnだけを書くと、そこで処理を終える。
「今の値」を受け取る書き方
setRemainingSeconds((prev) => prev - 1);remainingSeconds - 1ではなく、関数を渡す書き方にしている。prevには、その時点の最新の値が入る。
setIntervalの中でremainingSeconds - 1と書くと、useEffectが実行された時点の古い値を使い続けてしまい、残り時間が正しく減らない。タイマーのように、同じ関数が長く動き続ける処理では、この書き方を使う。
クリーンアップで、タイマーを止める
return () => clearInterval(id);setIntervalは、止めない限り動き続ける。useEffectの戻り値にclearIntervalを書いておくと、次のタイミングで自動的に実行される。
isActiveが変わって、useEffectが実行し直される前(一時停止した時など)- コンポーネントが画面から消える時
後片付けを書かないと、一時停止してもタイマーが止まらなかったり、再開するたびにタイマーが増えて、時間が速く減ったりする。
依存配列
}, [isActive]);isActiveが変わった時だけ、この処理を実行し直す。動き始めた時にタイマーを作り、止まった時に片付ける、という動きになる。
0秒になると、isActiveは自動的にfalseになる。そのため、クリーンアップが実行されて、タイマーは止まる。残り時間が、マイナスになることはない。
Controlsコンポーネント
ボタンの部分は、src/components/Controls.jsxとして、コンポーネントに分ける。
export default function Controls({
isActive,
hasStarted,
isFinished,
canStart,
onStart,
onPause,
onReset,
}) {
return (
<div className="buttons">
{isActive ? (
<button onClick={onPause}>一時停止</button>
) : (
<button onClick={onStart} disabled={!canStart || isFinished}>
{hasStarted ? "再開" : "開始"}
</button>
)}
<button className="secondary" onClick={onReset}>
リセット
</button>
</div>
);
}動いている間は「一時停止」、止まっている間は「開始」(一度始めたあとは「再開」)のボタンを表示する。入門編で学んだ三項演算子を、2か所で使っている。
このコンポーネントは、状態を持たない。表示に必要な値と、ボタンが押された時の処理は、すべて親のAppから、propsで受け取る。第1回で学んだ、「stateは親に置き、子にはpropsで渡す」形である。
SubjectInputの変更
一度始めたら、リセットするまでは、科目を変えられないようにする。SubjectInputに、disabledを受け取る変更を加える。
export default function SubjectInput({ value, onChange, disabled }) {
return (
<label className="subject">
科目
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="例:英語、数学、プログラミング"
disabled={disabled}
/>
</label>
);
}Appコンポーネント
src/App.jsxの全体である。
import { useEffect, useState } from "react";
import SubjectInput from "./components/SubjectInput.jsx";
import TimerDisplay from "./components/TimerDisplay.jsx";
import Controls from "./components/Controls.jsx";
import "./App.css";
const WORK_MINUTES = 25;
const TOTAL_SECONDS = WORK_MINUTES * 60;
export default function App() {
const [subject, setSubject] = useState("");
const [remainingSeconds, setRemainingSeconds] = useState(TOTAL_SECONDS);
const [isRunning, setIsRunning] = useState(false);
// state から計算できる値は、state にしない
const canStart = subject.trim() !== "";
const isFinished = remainingSeconds === 0;
const isActive = isRunning && !isFinished; // 実際に動いている状態
const hasStarted = remainingSeconds < TOTAL_SECONDS;
// 動いている間だけ、1秒ごとに残り時間を1減らす
useEffect(() => {
if (!isActive) return;
const id = setInterval(() => {
setRemainingSeconds((prev) => prev - 1);
}, 1000);
// 後片付け:タイマーを止める
return () => clearInterval(id);
}, [isActive]);
const handleStart = () => setIsRunning(true);
const handlePause = () => setIsRunning(false);
const handleReset = () => {
setIsRunning(false);
setRemainingSeconds(TOTAL_SECONDS);
};
let message;
if (isFinished) {
message = `お疲れさまでした!「${subject}」を${WORK_MINUTES}分、学習しました`;
} else if (isActive) {
message = `「${subject}」を学習中です`;
} else if (hasStarted) {
message = `「${subject}」を一時停止中です`;
} else if (canStart) {
message = `「${subject}」を${WORK_MINUTES}分、学習します`;
} else {
message = "科目を入力すると、開始できます";
}
return (
<main className="app">
<h1>学習タイマー</h1>
<SubjectInput
value={subject}
onChange={setSubject}
disabled={hasStarted}
/>
<TimerDisplay remainingSeconds={remainingSeconds} />
<Controls
isActive={isActive}
hasStarted={hasStarted}
isFinished={isFinished}
canStart={canStart}
onStart={handleStart}
onPause={handlePause}
onReset={handleReset}
/>
<p className="note">{message}</p>
</main>
);
}第1回と変わったのは、次の点である。
remainingSecondsとisRunningのstateを追加したuseEffectで、タイマーを動かすようにした- 3つの操作(開始・一時停止・リセット)の関数を作り、
Controlsに渡した - 状態に合わせて、メッセージを変えるようにした
TimerDisplayは、第1回から変更していない。「表示するだけ」のコンポーネントにしておいたので、残り時間が減るようになっても、そのまま使える。
CSS
入力欄が無効の時のスタイルを、src/App.cssの.timerの前に追加する。
.subject input:disabled {
color: #64748b;
background: #f1f5f9;
}動作確認
npm run devで起動して、次のことを確認しよう。
- 科目を入力して「開始」を押すと、「25:00」が「24:59」「24:58」……と減っていく
- 動いている間は、ボタンが「一時停止」になり、科目の入力欄が押せなくなる
- 「一時停止」を押すと、減るのが止まる。ボタンは「再開」になる
- 「再開」を押すと、続きから減っていく
- 「リセット」を押すと、「25:00」に戻り、科目を変えられるようになる
0秒まで動かして確認したい場合は、WORK_MINUTESを一時的に1に変えると、1分で終わる。確認が終わったら、25に戻しておこう。
実験:ずれを体験する
この方式のタイマーは、実は正確ではない。それを確かめてみよう。
- 科目を入力して、タイマーを開始する
- ブラウザで
F12キーを押して、開発者ツールを開き、「コンソール」を選ぶ - 次のコードを貼り付けて、
Enterキーを押す
const t = Date.now(); while (Date.now() - t < 5000) {}これは、5秒間、ブラウザの処理を止めるコードである。
(補足)コンソールに貼り付けられない場合
ChromeやEdgeでは、初めてコンソールに貼り付けようとすると、「allow pastingと入力してください」という警告が表示されて、貼り付けられないことがある。
これは、知らないコードを貼り付けて、思わぬ被害を防ぐための仕組みである。
その場合は、入力欄にallow pastingと手で入力して、Enterキーを押す。
そのあと、貼り付けられる。最初の1回だけでよい。
なお、この操作をせず、コードをそのまま手で入力しても構わない。
コードの実行が終わったら、タイマーを見てみよう。実際の経過時間より、遅れている。筆者の環境(Chrome)では、開始してから実際には11秒ほど経った時に、タイマーは7秒分しか進んでいなかった。5秒間止めたうち、4秒ほどの分が、数えられなかったことになる。
原因は、setIntervalの仕組みにある。
setIntervalは、「1秒ごとに1減らす」という命令であって、「時間を測る」機能ではない- 処理が詰まって、実行が遅れた分は、あとから取り戻してくれない
- ブラウザは、見ていないタブでは、タイマーの実行回数を減らすことがある。別のタブを開いたままにすると、同じようにずれる
25分のタイマーが、数分もずれてしまっては困る。
次回は、開始した時刻と、今の時刻の差から残り時間を計算する方式に変えて、ずれを解消する。
今回のまとめ
useEffectは、画面を作ること以外の処理(タイマーなど)を書く場所であるsetIntervalで、1秒ごとに処理を繰り返せるuseEffectの戻り値に、クリーンアップ(clearInterval)を書くと、後片付けができる- 依存配列に書いた値が変わった時だけ、
useEffectの処理が実行し直される - 古い値を使わないよう、
setStateには「今の値」を受け取る関数を渡す - stateから計算できる値は、stateにしない
- 1秒ずつ減らす方式は、処理が詰まったり、タブが裏に回ったりすると、ずれる
次回予告
第3回では、「開始した時刻」と「今の時刻」の差から残り時間を計算する方式に変えて、ずれないタイマーにする。第2回で扱ったuseEffectに、DateとuseRefを組み合わせる。


