Reactで学ぶ 学習タイマー開発(第2回)~タイマーを動かす~【GitHub対応】

GitHubでつくって学ぶ

はじめに

第1回では、「25:00」の表示と科目の入力欄を作った。
ただ、ボタンを押しても何も起きない、動かない画面だった。

第2回では、いよいよタイマーを動かす。useEffectとsetIntervalを使って、1秒ごとに残り時間を減らしていく。開始・一時停止・再開・リセットもできるようにする。

ただし、この方法にはずれるという弱点がある。最後に、それを実際に体験して確かめる。

今回やること

  • useEffectの基本的な使い方を知る
  • setIntervalで、1秒ごとに残り時間を減らす
  • クリーンアップ(後片付け)を書く
  • 開始・一時停止・再開・リセットのボタンを作る
  • 1秒ずつ減らす方式の「ずれ」を体験する

完成イメージ

開始すると、残り時間が1秒ずつ減っていく。

実行環境

第1回と同じである。(GitHub Codespaces)
ソースコードは、次の筆者のリポジトリの02_timerフォルダにある。
第1回の完成コードに、今回の変更を加えたものである。

GitHub – HappyTalk10/pc-labo-react-study-timer
Contribute to HappyTalk10/pc-labo-react-study-timer development by creating an account on GitHub.
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に戻しておこう。

実験:ずれを体験する

この方式のタイマーは、実は正確ではない。それを確かめてみよう。

  1. 科目を入力して、タイマーを開始する
  2. ブラウザでF12キーを押して、開発者ツールを開き、「コンソール」を選ぶ
  3. 次のコードを貼り付けて、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を組み合わせる。

タイトルとURLをコピーしました