Reactで、タイピング練習の画面を作る方法
タイピング練習の画面は、お手本の文と「次に打つ文字の位置」があれば作れます。押したキーが次の文字と同じなら位置を1つ進め、違えばミスとして数えます。最後の文字まで打ったら、かかった時間と正確率を出します。
キーは window の keydown で受け取ります。このとき、書き方しだいで、2文字目から進まなくなる、Shift をミスに数える、Space でボタンが押されて最初に戻る、といったことが起きます。どれも、書き換えたものと比べて確かめました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、キー入力は Playwright で送りました。お手本は英語の文だけで、ひらがなの文をローマ字で打つ練習は扱っていません。日本語入力(IME)をオンにしたままの入力、実物のキーボードでの入力、Firefox と Safari、スマートフォンのブラウザは試していません。

打った状態を1つの reducer にまとめる
打っている途中の状態は、まとめて1つのオブジェクトにしました。位置、ミスの数、打ち始めた時刻などは、1回のキーでいっしょに変わるからです。キーを1つ受け取ったときの変化は、React から切り離した typing.ts の typingReducer に書きます。
export type TypingState = {
text: string; // お手本の文
pos: number; // 次に打つ文字の位置(ここまでは正しく打てている)
keystrokes: number; // 数えたキーの回数(正しいキーとまちがえたキーの合計)
misses: number; // まちがえたキーの回数
missed: boolean; // いまの位置で、直前にまちがえたか(表示用)
startedAt: number | null; // 最初のキーを押した時刻(ミリ秒)
finishedAt: number | null; // 最後の文字を打った時刻(ミリ秒)
};
export type TypingAction =
| { type: 'key'; key: string; time: number }
| { type: 'reset'; text: string };
export function initTyping(text: string): TypingState {
return { text, pos: 0, keystrokes: 0, misses: 0, missed: false, startedAt: null, finishedAt: null };
}
export function typingReducer(s: TypingState, a: TypingAction): TypingState {
if (a.type === 'reset') return initTyping(a.text);
if (s.finishedAt !== null) return s; // 打ち終わったら、それ以上は数えない
const startedAt = s.startedAt ?? a.time;
if (a.key !== s.text[s.pos]) {
// まちがえたら、位置は進めずに回数だけ数える
return { ...s, keystrokes: s.keystrokes + 1, misses: s.misses + 1, missed: true, startedAt };
}
const pos = s.pos + 1;
return {
...s, pos, keystrokes: s.keystrokes + 1, missed: false, startedAt,
finishedAt: pos === s.text.length ? a.time : null,
};
}
// 打ち終わったときの成績
export function typingResult(s: TypingState) {
if (s.startedAt === null || s.finishedAt === null) return null;
const seconds = (s.finishedAt - s.startedAt) / 1000;
return {
seconds,
perMinute: seconds > 0 ? Math.round((s.text.length / seconds) * 60) : 0, // 1分あたりに正しく打てた文字数
accuracy: Math.round(((s.keystrokes - s.misses) / s.keystrokes) * 1000) / 10, // 正確率(%)
};
}
まちがえたキーを押したときは、位置を進めずに misses だけを増やします。打ち直しのために Backspace を押す必要はなく、正しいキーを押せばそのまま先へ進みます。missed は、まちがえた文字をピンクで示すためのものです。正しいキーを押すと false に戻ります。
時刻は reducer の中で取らず、キーを受け取った側で performance.now() を呼んで time として渡しています。こうしておくと、reducer は渡された state とアクションだけで次の状態を決める関数になります。React の公式リファレンスにある useReducer の注意点には、reducer は純粋でなければならないこと、Strict Mode では開発中に reducer が2回呼ばれることが書かれています。
キーは window の keydown で受け取る
コンポーネントは次のとおりです。入力欄は置かず、ページのどこにフォーカスがあってもキーを受け取れるように、window に keydown のリスナーを付けています。
import { useEffect, useReducer, useState } from 'react';
import { initTyping, typingReducer, typingResult } from './typing';
const TEXTS = [
'the quick brown fox jumps over the lazy dog',
'Practice makes perfect.',
'Never put off till tomorrow what you can do today.',
];
type Props = { texts?: string[] };
export function TypingPractice({ texts = TEXTS }: Props) {
const [round, setRound] = useState(0);
const [state, dispatch] = useReducer(typingReducer, texts[0], initTyping);
const result = typingResult(state);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey) return; // ショートカットはブラウザに任せる
if (e.key.length !== 1) return; // Shift、Enter、Backspace などは数えない
e.preventDefault(); // Space でページがスクロールしたり、ボタンが押されたりしないように
dispatch({ type: 'key', key: e.key, time: performance.now() });
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
function start(next: number) {
setRound(next);
dispatch({ type: 'reset', text: texts[next % texts.length] });
}
return (
<div className="typing">
<p className="text">
{[...state.text].map((ch, i) => (
<span
key={i}
className={i < state.pos ? 'done' : i === state.pos ? (state.missed ? 'current miss' : 'current') : ''}
>
{ch}
</span>
))}
</p>
<p className="progress">
{state.pos} / {state.text.length} 文字 ミス {state.misses}
</p>
<p className="result" role="status">
{result && `${result.seconds.toFixed(1)}秒 1分あたり ${result.perMinute} 文字 正確率 ${result.accuracy}%`}
</p>
<button type="button" onClick={() => start(round)}>やり直す</button>
{result && <button type="button" onClick={() => start(round + 1)}>次の文へ</button>}
</div>
);
}
お手本の文は、1文字ずつ <span> に分けて描きます。打ち終わった文字には done、次に打つ文字には current のクラスを付けます。直前にまちがえていれば、current に miss を足します。見た目は CSS で付けました。
.typing { font-family: sans-serif; color: #1f2a30; }
.text {
font-family: ui-monospace, monospace; font-size: 22px; line-height: 1.8;
white-space: pre-wrap; padding: 12px 16px; background: #eefafd; border-radius: 8px;
}
.text .done { color: #9aa5ab; }
.text .current { border-bottom: 3px solid #00c8e8; }
.text .miss { background: #d85878; color: #fff; border-bottom-color: #d85878; }
.progress { color: #067a98; }
打ち終わった文字は灰色になり、次に打つ文字には水色の下線が付きます。まちがえた文字は、ピンクの背景に白い文字になります。
Playwright で「the quick brown fox」をお手本にして試しました。t を押すと「1 / 19 文字 ミス 0」になり、下線が h に移りました。続けて x を押すと、位置は h のままで「ミス 1」になり、h に miss のクラスが付きました。そこで h を押すと、「2 / 19 文字 ミス 1」になりました。
state を Effect の中から読むと、2文字目から進まない
リスナーは、依存配列を [] にして、マウントのときに登録しています。そのため、リスナーの中で state を読むと、いつまでも最初のレンダーのときの値のままです。
比べるために、useReducer の代わりに useState で状態を持ち、リスナーの中で setState(typingReducer(state, …)) と書いたものを試しました。t を押すと「1 / 19 文字」になりました。ところが、次に h を押すと「0 / 19 文字 ミス 1」になり、下線が t に戻りました。リスナーが読んでいる state は、まだ何も打っていないときのものです。そのため、h は「最初の文字 t とは違う」と判定されました。続けて e を押しても「0 / 19 文字 ミス 1」のままで、何も進みませんでした。
記事のコードでは、リスナーは dispatch に「どのキーが押されたか」を渡すだけです。いまの状態は React が reducer に渡すので、リスナーが state を読む必要はありません。同じ useReducer の注意点には、dispatch 関数は常に同一のものなので、多くの場合エフェクトの依存配列では省略される、と書かれています。リスナーから最新の値を読む別の書き方(useEffectEvent)は、以前の記事「Reactのカスタムフックでキーボードショートカットを作る方法」で使っています。
クリーンアップを返さないと、開発中は1回のキーを2回数える
useEffect からは、removeEventListener を呼ぶ関数を返しています。これを返さないものも試しました。
StrictMode で包み、開発用のビルドで動かすと、t を1回押しただけで「1 / 19 文字 ミス 1」になりました。1つ目のリスナーで t が正しく打たれて h へ進み、2つ目のリスナーが同じ t を h と比べてミスに数えています。React の公式リファレンスにある StrictMode の説明には、Strict Mode は開発中にすべてのエフェクトでセットアップとクリーンアップをもう1回ずつ実行する、と書かれています。クリーンアップがないと、window にリスナーが2つ残ります。
同じコードを本番用のビルドで動かすと、t を1回押して「1 / 19 文字 ミス 0」でした。記事のコードは、開発用のビルドで StrictMode に包んでも「1 / 19 文字 ミス 0」でした。
数えるのは、文字になるキーだけ
keydown は、文字を打つキーだけでなく、Shift や Enter、Backspace でも起きます。MDN にある KeyboardEvent.key の説明には、表示できる文字のキーなら、その文字が key に入ると書かれています。B のキーなら "b"、Shift を押しながらなら "B"、スペースのキーなら " " です。Shift そのものを押したときの key は "Shift" です。
記事のコードでは、e.key.length !== 1 のキーを数えずに返しています。「Practice makes perfect.」をお手本にして、Shift を押しながら P を打つと、「1 / 23 文字 ミス 0」になりました。
この行を外したもので同じように打つと、「1 / 23 文字 ミス 1」になりました。P は正しく打てていますが、その前に押した Shift がミスに数えられています。続けて Enter を押すと、「ミス 2」に増えました。大文字や記号を打つたびに、ミスが1つ増えてしまいます。
Ctrl、Alt、Meta を押しながらのキーも数えません。ショートカットに使われるキーの組み合わせを、ミスに数えないためです。この場合は preventDefault も呼ばずに返します。検証では、Ctrl+A、Enter、Backspace を押しても、位置もミスの数も変わりませんでした。
Space でページが動かないように、既定の動作を止める
数えるキーでは、e.preventDefault() を呼んでいます。これを外したものと比べました。
1つ目は、ページのスクロールです。ページを縦に長くして、何もフォーカスしていない状態で「the 」と打ちました。記事のコードでは scrollY が 0 のままでしたが、外したものでは 630 になりました。Space を押すと、ページが下へスクロールしました。
2つ目は、ボタンです。「th」まで打ってから「やり直す」をマウスで押すと、ボタンにフォーカスが残りました。そのまま「the quick」と打つと、記事のコードでは「9 / 19 文字 ミス 0」まで進みました。preventDefault を外したものでは「0 / 19 文字 ミス 5」になりました。Space で「やり直す」が押されて最初に戻り、その後の「quick」の5文字が、最初の文字 t と比べてミスに数えられています。
「次の文へ」のボタンは、押すと結果といっしょに消えます。検証では、押した後のフォーカスはボタンに残らず、body に移っていました。
時間と正確率を出す
時間は、最初のキーを押した時刻から、最後の文字を打った時刻までです。1つ目のキーがまちがいでも、そこから測り始めます。
typingResult では、次の2つを出しています。
- 1分あたりの文字数:お手本の文字数を、かかった秒数で割って60を掛けたもの
- 正確率:数えたキーのうち、正しかったキーの割合
確かめるときは、Playwright の page.clock でページの時計を止め、決まった時間ずつ進めました。「Practice makes perfect.」(23文字)を、1文字打つたびに 250ms ずつ時計を進めながら打ちました。途中で1回、m の代わりに n を押しています。n の後は時計を進めずに、続けて m を押しました。結果は次のように表示されました。
23 / 23 文字 ミス 1
5.5秒 1分あたり 251 文字 正確率 95.8%
時計を進めたのは文字と文字の間の22か所なので、かかった時間は 5.5 秒です。1分あたりの文字数は 23 ÷ 5.5 × 60 で、四捨五入して 251 になります。キーは全部で24回押していて、そのうち正しかったのが23回なので、正確率は 95.8% です。
打ち終わると、typingReducer はそれ以上キーを数えません。結果が出た後に x を押しても、表示は変わりませんでした。





