Reactで学ぶ 学習タイマー開発(第1回)~画面を作る~【GitHub対応】

Uncategorized

はじめに

「PC-LABOを試すためのJavaScript・React入門シリーズ」と「Reactで学ぶ 予定管理アプリ開発シリーズ」では、useStateを中心に、Reactの基本を学んだ。

今回から始めるこの新シリーズでは、学習タイマーを作る。科目を入力して25分のタイマーを動かし、勉強した時間を記録・集計するアプリである。予定管理アプリでは扱わなかった、次の内容を学ぶ。

  • useEffect(画面の外の世界とやり取りする仕組み)
  • useRef
  • カスタムフック
  • SVGを使った、アナログ時計の描画

第1回は、アプリの全体像を確認して、まずは動かない画面を作る。

今回やること

  • 作るアプリと、シリーズの全体像を知る
  • 「25:00」の表示と、科目の入力欄を作る
  • 科目が空の間は、開始ボタンを押せないようにする

完成イメージ

今回の完成画面である。

ボタンはまだ動かない。タイマーは第2回で動かす。

シリーズの全体像

回内容主に学ぶこと
第1回画面を作るuseStateの復習、propsで関数を渡す
第2回タイマーを動かすuseEffect、setInterval、クリーンアップ
第3回ずれないタイマーにするDate、useRef
第4回アナログ時計で表示するSVG、requestAnimationFrame
第5回終了をお知らせする画面表示、タブのタイトル、音
第6回記録を保存するlocalStorage、カスタムフック
第7回集計して公開する集計、GitHub Pagesでの公開
発展編ブラウザ通知、扇形タイマーNotification、三角関数

なお、このアプリは学習の内容(問題など)は扱わない。「何を勉強したか」と「何分勉強したか」を記録するだけのアプリである。

実行環境

これまでと同じく、GitHub Codespacesを使う。Node.jsは22系である。

ソースコードは、次の筆者のリポジトリにある。各フォルダは、その回が終わった時点の完成コードである。

GitHub – HappyTalk10/pc-labo-react-study-timer
Contribute to HappyTalk10/pc-labo-react-study-timer development by creating an account on GitHub.

このリポジトリからCodespacesを作ると、.devcontainerの設定が自動で効いて、Node.js 22系の環境になる。

今回のコードは、01_screenフォルダにある。Codespacesのターミナルで、次のコマンドを実行する。

cd 01_screen
npm install
npm run dev

npm run devは、package.jsonのscriptsに書かれたviteコマンドを実行して、開発用のサーバーを起動する。表示されるhttp://localhost:5173/を、「ブラウザーで開く」のボタンから開こう。止める時は、ターミナルでCtrl + Cを押す。

自分のリポジトリで一から作る場合

このリポジトリのコードを使わず、自分のリポジトリとCodespacesで一から作る場合は、次の順に進めよう。
1.GitHubで新しいリポジトリを作り、Codespacesを起動する
2..devcontainer/devcontainer.jsonを作り、Node.jsを22系にそろえる

{
  "name": "React Study Timer",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm"
}

3.Codespacesを起動した直後は、.devcontainerの設定がまだ効いていない。
設定は、Codespacesを作る時か、環境を作り直す時に読み込まれるためである。
F1キーでコマンドパレットを開き、「Codespaces: Rebuild Container」を実行して、環境を再構築する。作業中のファイルは消えない
4.再構築が終わったら、node -vでv22から始まる表示になることを確認する
5.次のコマンドで、プロジェクトを作る

npm create vite@latest 01_screen -- --template react cd 01_screen npm install

Node.jsのバージョンが少し違っても、動くことが多い。ただ、この記事の環境と手順をそろえるため、22系にしておくことをおすすめする。
最初から入っているサンプルのコード(src/assetsフォルダやApp.cssの中身など)は不要なので、この記事のコードに置き換える。対話形式の質問が表示された場合の選択肢は、Viteのバージョンによって少し違うので、画面の案内に従えばよい。

ファイル構成

01_screen/
├── index.html
├── package.json
├── vite.config.js
└── src/
    ├── main.jsx
    ├── index.css
    ├── App.jsx
    ├── App.css
    ├── components/
    │   ├── SubjectInput.jsx
    │   └── TimerDisplay.jsx
    └── utils/
        └── time.js

入口のmain.jsxは、入門編の第3回と同じ形で、Appを画面に描画している。違いは、import "./index.css";の1行だけである。これは、画面全体の背景色などを決めるCSSを読み込んでいる。

秒数を「25:00」の形にする

タイマーの残り時間は、秒数で持つことにする。25分なら1500である。これを「25:00」の形の文字列にする関数を、src/utils/time.jsに作る。

// 秒数を "25:00" の形式の文字列にする
export const formatTime = (totalSeconds) => {
  const minutes = Math.floor(totalSeconds / 60);
  const seconds = totalSeconds % 60;
  const pad = (n) => String(n).padStart(2, "0");
  return `${pad(minutes)}:${pad(seconds)}`;
};
  • Math.floor(totalSeconds / 60):60で割って、小数点以下を切り捨てる。これが「分」である
  • totalSeconds % 60:60で割った余りを求める。これが「秒」である
  • pad:2桁に満たない数字の左側を0で埋める。入門編の第2回で扱った、padStartである

結果は、次のようになる。

渡す値結果
1500"25:00"
65"01:05"
59"00:59"
0"00:00"

exportを付けているので、他のファイルからimportして使える。入門編の第2回で扱った、名前付きエクスポートである。

TimerDisplayコンポーネント

「25:00」を表示するコンポーネントである。src/components/TimerDisplay.jsxに作る。

import { formatTime } from "../utils/time.js";

// 残り時間(秒)を受け取って、「25:00」の形で表示する
export default function TimerDisplay({ remainingSeconds }) {
  return <p className="timer">{formatTime(remainingSeconds)}</p>;
}

remainingSeconds(残り秒数)をpropsで受け取り、formatTimeで文字列にして表示する。このコンポーネントは、表示するだけで、時間を数える処理は持たない。第2回以降で時間が減っていくようになっても、このコンポーネントは変更しなくてよい。

importのパスにある../は、「1つ上のフォルダ」という意味である。componentsフォルダの1つ上のsrcの中にある、utils/time.jsを指している。

SubjectInputコンポーネント

科目の入力欄である。src/components/SubjectInput.jsxに作る。

export default function SubjectInput({ value, onChange }) {
  return (
    <label className="subject">
      科目
      <input
        type="text"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder="例:英語、数学、プログラミング"
      />
    </label>
  );
}

入門編の第3回で作った入力欄では、コンポーネントの中にuseStateを持っていた。今回は違い、stateを持たず、次の2つをpropsで受け取っている。

  • value:入力欄に表示する文字列
  • onChange:文字が入力された時に呼ぶ関数

propsには、文字列だけでなく、関数も渡せる。文字が入力されると、入力された文字列を引数にして、onChangeを呼び出している。

Appコンポーネント

画面全体をまとめる、src/App.jsxである。

import { useState } from "react";
import SubjectInput from "./components/SubjectInput.jsx";
import TimerDisplay from "./components/TimerDisplay.jsx";
import "./App.css";

const WORK_MINUTES = 25;

export default function App() {
  const [subject, setSubject] = useState("");

  // 第1回は固定の値。第2回から、減っていくようにする
  const remainingSeconds = WORK_MINUTES * 60;

  // 科目が空(または空白だけ)の時は、開始できない
  const canStart = subject.trim() !== "";

  return (
    <main className="app">
      <h1>学習タイマー</h1>

      <SubjectInput value={subject} onChange={setSubject} />

      <TimerDisplay remainingSeconds={remainingSeconds} />

      <div className="buttons">
        <button disabled={!canStart}>開始</button>
        <button className="secondary">リセット</button>
      </div>

      <p className="note">
        {canStart
          ? `「${subject}」を${WORK_MINUTES}分、学習します`
          : "科目を入力すると、開始できます"}
      </p>
    </main>
  );
}

stateは、親のAppに置く

科目のuseStateは、SubjectInputの中ではなく、親のAppに置いている。

<SubjectInput value={subject} onChange={setSubject} />

setSubject(stateを変更する関数)は、そのままonChangeとして渡せる。文字を入力すると、次の順に動く。

  1. 入力欄のonChangeが呼ばれる
  2. SubjectInputが、渡されたonChange(実体はsetSubject)を呼ぶ
  3. Appのsubjectが変わり、画面が書き換わる

科目は、入力欄だけでなく、開始ボタンや下のメッセージ、そして後には記録の保存でも使う。そのため、複数のコンポーネントから使える、一番上のAppに置く。このように、stateを親に置いて、子にはpropsで渡す形は、Reactで頻繁に使う。

開始ボタンを押せなくする

const canStart = subject.trim() !== "";

trim()は、文字列の前後の空白を取り除くメソッドである。空白だけが入力された場合も、「空」として扱える。canStartは、科目が入力されている時だけtrueになる。

<button disabled={!canStart}>開始</button>

disabledは、ボタンを押せなくする属性である。!canStartはcanStartの反対なので、科目が空の間は、ボタンが押せなくなる。

下のメッセージは、入門編の第1回・第2回で学んだ、三項演算子とテンプレートリテラルで書いている。

CSS

見た目を整えるCSSである。React自体の内容ではないので、コピペして使っていただきたい。

src/index.css(画面全体の背景色など)である。

body {
  margin: 0;
  background: #f8fafc;
  color: #1f2937;
}

src/App.css(画面のスタイル)である。

.app {
  max-width: 420px;
  margin: 40px auto;
  padding: 24px;
  text-align: center;
  font-family: system-ui, sans-serif;
}

.app h1 {
  margin: 0 0 24px;
  font-size: 1.6rem;
}

.subject {
  display: flex;
  flex-direction: column;
  gap: 6px;
  text-align: left;
  font-size: 0.95rem;
}

.subject input {
  padding: 10px 12px;
  font-size: 1rem;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
}

.timer {
  margin: 32px 0;
  font-size: 4.5rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.buttons {
  display: flex;
  gap: 12px;
  justify-content: center;
}

.buttons button {
  padding: 10px 28px;
  font-size: 1rem;
  color: #ffffff;
  background: #0f766e;
  border: none;
  border-radius: 8px;
  cursor: pointer;
}

.buttons button.secondary {
  color: #1f2937;
  background: #e2e8f0;
}

.buttons button:disabled {
  color: #94a3b8;
  background: #e2e8f0;
  cursor: not-allowed;
}

.note {
  margin-top: 20px;
  font-size: 0.9rem;
  color: #64748b;
}

JSXのclassName="timer"と、CSSの.timerが対応している。font-variant-numeric: tabular-numsは、数字の幅をそろえる指定である。タイマーの数字が変わっても、表示が左右にぶれなくなる。

動作確認

npm run devで起動して、次のことを確認しよう。

  • 「25:00」が表示される
  • 最初は「開始」ボタンが押せない(グレーになっている)
  • 科目に「英語」と入力すると、「開始」ボタンが押せるようになり、下に「「英語」を25分、学習します」と表示される
  • 入力を消すと、再び押せなくなる
  • 空白だけを入力しても、押せるようにはならない

今回のまとめ

  • 学習タイマーは、科目を入力して25分を計り、勉強時間を記録・集計するアプリである
  • 残り時間は、秒数で持つ。表示する時だけ、formatTimeで「25:00」の形にする
  • 表示するだけのコンポーネント(TimerDisplay)は、propsで値を受け取って表示する
  • stateは、複数のコンポーネントから使うので、親のAppに置く。子には、値と、値を変える関数をpropsで渡す
  • disabledで、ボタンを押せなくできる

次回予告

第2回では、いよいよタイマーを動かす。useEffectとsetIntervalを使って、1秒ごとに残り時間を減らしていく。ただし、この方法には「ずれる」という弱点がある。それも、実際に体験して確かめる。

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