はじめに
「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系である。
ソースコードは、次の筆者のリポジトリにある。各フォルダは、その回が終わった時点の完成コードである。
このリポジトリからCodespacesを作ると、.devcontainerの設定が自動で効いて、Node.js 22系の環境になる。
今回のコードは、01_screenフォルダにある。Codespacesのターミナルで、次のコマンドを実行する。
cd 01_screen
npm install
npm run devnpm 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 installNode.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として渡せる。文字を入力すると、次の順に動く。
- 入力欄の
onChangeが呼ばれる SubjectInputが、渡されたonChange(実体はsetSubject)を呼ぶ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秒ごとに残り時間を減らしていく。ただし、この方法には「ずれる」という弱点がある。それも、実際に体験して確かめる。


