【React 初心者】React でタイピング練習アプリ!

 

前回、React で文字当てゲームを作りました。

今回は、このようなタイピング練習アプリを作ってみました。


Edit react-simple-typing-app

この記事では、このタイピングアプリの実装について解説しながら、

少々ですが練習のために使用した、

  • React Hooks
  • コンポーネントと props
  • Material-UI
  • TypeScript

についても書いていきます。

これらの知識がすでにある方は、最初の4項目は読み飛ばしていただいても構いません。

関連記事

【React 初心者】React で文字当てゲーム!

【React 初心者】React で文字当てゲーム!


React Hooks (useState)

前回の文字当てゲームでは、クラスを定義して state を使っていました。

しかし、React Hooks という機能を使うことで、関数の中で state を扱うことができるようになります。

例えば、このように書いていたのを、

/src/App.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import React, { Component } from 'react';

export default class App extends Component {
	constructor(props) {
		super(props);
		this.state = {
			firstState: 1,
			secondState: false,
		};
	}

	setValue = () => {
		const { firstState } = this.state;
		this.setState({
			firstState: firstState + 1,
		});
	};

	render() {
		const { firstState, secondState } = this.state;

		return (
			<div>
				<p>{firstState}</p>
				<p>{secondState}</p>
			</div>
		);
	}
}

次のように簡潔に書くことができるようになります。

/src/App.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import React, { useState } from 'react';

export default function App() {
	const [firstState, setFirstState] = useState(1);
	const [secondState, setSecondState] = useState(false);

	const setValue = () => setFirstState(firstState + 1);

	return (
		<div>
			<p>{firstState}</p>
			<p>{secondState}</p>
		</div>
	);
}

const [firstState, setFirstState]firstStateは変数(state)名、setFirstStateはその変数を変更させるための関数名です。

useState()の引数に変数の初期値を渡します。

Hooks を使うことで、関数内部のどこからでも宣言なしでさっと state を参照できて、とても便利です。


コンポーネントと props

コンポーネントとは「部品」のことです。

次のように使います。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import React from 'react';

function MyComponent() {
	return <p>This is my component.</p>;
}

function AnotherComponent() {
	return (
		<div>
			"MyComponent" comes here.
			<MyComponent />
		</div>
	);
}

<MyComponent />と書いた部分に、

<p>This is my component.</p>が展開されます。


props は「外部から値を入れられる変数」のことです。

関数の引数みたいなイメージです。

関数を呼び出すときに引数を渡すのと同じように、次のように書きます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import React from 'react';

function MyComponent(props) {
	return <p>This is my {props.adjective} component.</p>;
}

function AnotherComponent() {
	return (
		<div>
			"MyComponent" comes here.
			<MyComponent adjective='great' />
		</div>
	);
}

これで<MyComponent adjective="great" />と書いた部分に、

<p>This is my great component.</p>が展開されます。

なお、3~5行目は props を省略して次のように書くこともできます。

1
2
3
function MyComponent({ adjective }) {
	return <p>This is my {adjective} component.</p>;
}

ちなみに、コンポーネント名の先頭は大文字である必要があります。


Material-UI

Material-UI は、React コンポーネントを使って マテリアルデザイン Google が提唱したデザイン方法
現実世界の物理的・物質的な性質を取り入れることで、
直感的な操作性を実現する
を実現できるライブラリです。

npm install @material-ui/coreとターミナルに打ってインストールすることで、

すぐに使い始めることができます。


いろいろなコンポーネントが用意されていて、JSX に書き込むだけで、

その位置にきれいなデザインのボタンやカード、ダイアログなどを表示できます。


例えば、ボタンを表示させるには、次のようにします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import React from 'react';
import Button from '@material-ui/core/Button';

function ButtonSample() {
  const handleClick = () => // doSomething();

  return (
    <div>
      <Button onClick={handleClick} variant="contained" color="primary">
        Click me!
      </Button>
    </div>
  );
}

公式ドキュメントには、どうすればどのようなデザインになるのかについて詳しく書かれています。

これで簡単にきれいなデザインを実装することができます。


TypeScript

TypeScript とは、型付けができる JavaScript です。

定数や変数を定義するときに、「これは文字列だ」「これは数値だ」と決めておくことで、

予期せぬエラーを未然に防ぐことができるようになります。

Create React App で TypeScript を使う場合、プロジェクト作成の際に--typescriptオプションをつけるだけで、

TypeScript による開発を始めることができます。

npx create-react-app my-react-app --typescript

実装

さて、ここから実際の実装作業に入っていきます。

ボタンコンポーネントを作る

まずはボタンのためのコンポーネントを作ります。

componentsフォルダの下にMyButton.tsxを作成します。

/src/components/MyButton.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import React from 'react';
import Button from '@material-ui/core/Button';

type Props = {
	children: any,
	onClick: () => void,
};

export default function MyButton({ children, onClick }: Props) {
	return (
		<Button onClick={onClick} variant='contained' color='primary'>
			{children}
		</Button>
	);
}

4~7行目では、MyButton関数の引数の型をまとめて定義しています。

とはいってもanyは「何でも OK」というものなので、あまり意味がなかったりしますが、

だとしても型定義はしないと怒られます。


() => voidは返り値のない関数であることを示します。


ところで Material-UI のButtonは、デフォルトではテキストがすべて大文字になってしまうので、

この設定を解除します。

/src/components/MyButton.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import React from 'react';
import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

const styles = createMuiTheme({
	typography: {
		button: {
			textTransform: 'none',
		},
	},
});

type Props = {
	children: any,
	onClick: () => void,
};

export default function MyButton({ children, onClick }: Props) {
	return (
		<ThemeProvider theme={styles}>
			<Button onClick={onClick} variant='contained' color='primary'>
				{children}
			</Button>
		</ThemeProvider>
	);
}

ButtonコンポーネントをThemeProviderコンポーネントで囲み、

その props のthemeに、5行目で定義したスタイルを指定しています。

これでボタンにスタイルが適用され、ボタンの文字が大文字化しなくなります。


ここでexportしたコンポーネントを、App.tsxでインポートして使用します。

/src/App.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import React from 'react';
import MyButton from './components/MyButton';

export default function App() {
	return (
		<div className='App'>
			<MyButton>ON</MyButton>
		</div>
	);
}

state と構成を決める

今回のアプリで必要となる state は、

  • 表示する文字
  • ボタンで入力可能・不可能を切り替えるブール変数
  • 現在入力している位置
  • タイプミスをした位置の配列

となります。


まず、ボタンで入力可能・不可能を切り替えるためのブール変数を用意します。

/src/App.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import React, { useState } from 'react';
import MyButton from './components/MyButton';

export default function App() {
	const [typing, setTyping] = useState(false);

	const typingToggle = () => setTyping(typing ? false : true);

	return (
		<div className='App'>
			<MyButton onClick={typingToggle}>{typing ? 'OFF' : 'ON'}</MyButton>
		</div>
	);
}

ボタンを押すとtypingToggle関数が動作し、typingを変更します。

ボタンの表示は ON OFF と切り替わります。


次に、タイピング用のテキストを用意します。

これはリセットボタンを押すと変化するようにするので、state に入れています。

/src/App.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import React, { useState } from 'react';
import MyButton from './components/MyButton';

export default function App() {
	const [text, setText] = useState('test text');
	const [typing, setTyping] = useState(false);

	const typingToggle = () => setTyping(typing ? false : true);

	return (
		<div className='App'>
			<div id='textbox'>{text}</div>
			<MyButton onClick={typingToggle}>{typing ? 'OFF' : 'ON'}</MyButton>
		</div>
	);
}

キー入力を処理する

React には合成イベントというものがあり、

キーボードイベントやホイールイベントを処理することができます。

onKeyPressを使って、とりあえず入力したキーを表示させてみます。

/src/App.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import React, { useState } from 'react';
import MyButton from './components/MyButton';

export default function App() {
	const [text, setText] = useState('test text');
	const [typing, setTyping] = useState(false);

	const typingToggle = () => setTyping(typing ? false : true);

	const handleKey = (e: React.KeyboardEvent<HTMLDivElement>) => setText(e.key);

	return (
		<div className='App' onKeyPress={(e) => handleKey(e)} tabIndex={0}>
			<div id='textbox'>{text}</div>
			<MyButton onClick={typingToggle}>{typing ? 'OFF' : 'ON'}</MyButton>
		</div>
	);
}

onKeyPressの部分に指定した関数が、なんらかのキーが押されたときに動作します。

tabIndexを指定しないとキーに反応しないので注意してください。


React.KeyboardEvent<HTMLDivElement>というのは TypeScript の型定義で、

VS Code であれば Quick Fix 機能を使っていい感じに補完してくれたりします。


これで入力したキーを認識し、表示することができます。

React キーボードイベント テスト


さて、ここからタイピングアプリにしていくので、あらかじめ用意された文字列と比較して、

文字が一致するかどうかで表示を変えることになります。

とりあえず、入力し終わった文字を黒、まだ入力していない文字をグレーで表示させてみましょう。

現在どこの文字を入力しようとしているか(位置)の情報が必要なので、state に追加します。

/src/App.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import React, { useState } from 'react';
import MyButton from './components/MyButton';
import './App.scss';

export default function App() {
	const [text, setText] = useState('test text');
	const [typing, setTyping] = useState(false);
	const [position, setPosition] = useState(0);

	const typingToggle = () => setTyping(typing ? false : true);

	const handleKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
		// 入力可能のとき
		if (typing) {
			// 入力したキーと現在入力しようとしている文字が一致するとき
			if (e.key === text[position]) {
				// 次の位置へ移動
				setPosition(position + 1);
			}
		}
	};

	return (
		<div className='App' onKeyPress={(e) => handleKey(e)} tabIndex={0}>
			<div id='textbox'>
				<span className='typed-letters'>{text.slice(0, position)}</span>
				<span className='waiting-letters'>{text.slice(position)}</span>
			</div>
			<MyButton onClick={typingToggle}>{typing ? 'OFF' : 'ON'}</MyButton>
		</div>
	);
}

今回は SCSS を使っていますが、CSS でも構いません。

SCSS を使うためには、ターミナルでnpm install -D node-sassと入力しておきます。


正しい文字を打つと文字が黒くなり、正しい文字を打たないと先に進みません。

また、ボタンで文字入力不可の状態にすると、何を打っても反応しなくなります。

React タイピング テスト

打ち間違えた文字を赤くする

打ち間違えた文字を、その瞬間だけ赤くするだけなら、

「入力し終わった文字」「現在入力待ちの文字」「まだ入力されていない文字」の3つに分ければ済みますが、

入力し終わった文字でも打ち間違えていたものは赤くするという場合、

文字ひとつひとつに色付けをしていくことになります。

そのために、ひとつひとつの文字をspanタグに入れて、そのclassNameを動的に変更することで、

文字が黒かグレーか赤かを決めます。


これを実現するためにmapメソッドを使い、mapを使うためにsplitメソッドで文字列を配列に変換します。

ただ、最初の1文字だけははじめから「現在入力待ちの文字」にしています。

/src/App.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import React, { useState } from 'react';
import MyButton from './components/MyButton';
import "./App.scss";

export default function App() {
  const [text, setText] = useState("test text");
  const [typing, setTyping] = useState(false);
  const [position, setPosition] = useState(0);
  const [typo, setTypo] = useState(new Array(0));

  const typingToggle = () => setTyping(typing ? false : true);

  const handleKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
    // 入力可能のとき
    if (typing) {
      // 文字の配列を取得
      let textSpans = document.querySelector("#textbox")!.children;
      // 入力したキーと現在入力しようとしている文字が一致するとき
      if (e.key === text[position]) {
        // 現在の文字を入力済とする
        textSpans[position].classList.add("typed-letters");
        textSpans[position].classList.remove("current-letter");
        // まだ入力していない文字があるとき
        if (position <= text.length - 2) {
          // 次の位置へ移動
          textSpans[position + 1].className = "current-letter";
          setPosition(position + 1);
        // 全ての文字を入力し終わったとき
        } else {
          // 入力不可にする
          setTyping(false);
        }
      // 間違ったキーを入力したとき
      } else {
        // その位置でのはじめての打ち間違えであるとき
        if (typo.indexOf(position) === -1) {
          // 打ち間違えた位置の配列にその位置を追加
          setTypo([...typo, position]);
          // 打ち間違えた文字であることを示す class を追加
          textSpans[position].classList.add("typo");
        }
      }
    }
  };

  return (
    <div className="App" onKeyPress={e => handleKey(e)} tabIndex={0}>
      <div id="textbox">
        <span className="current-letter">{text[0]}</span>
          {text
            .split("")
            .slice(1)
            .map(char => (
              <span className="waiting-letters">{char}</span>
            ))}
      </div>
      <MyButton onClick={typingToggle}>{typing ? "OFF" : "ON"}</MyButton>
    </div>
  );
}

17 行目のdocument.querySelector("#textbox")!!は、

document.querySelector("#textbox")nullになる可能性があるために TypeScript に怒られることを避けるためにつけています。

nullでないときにのみ動くようになります。


あとは CSS のほうで色の設定をします。

ついでに、「現在入力待ちの文字」の位置に下線を表示して点滅させます。

だいぶ完成に近づいてきました!

リセットボタンをつくる

リセットすると、すべての文字が入力待ちになり、打ち間違えもリセットされます。

/src/App.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
...
const refresh = () => {
  // 文字の配列を取得
  let textSpans = document.querySelector("#textbox")!.children;
  // すべての文字のクラス名を変更
  for (const i of textSpans) {
    i.className = "waiting-letters";
  }
  // 最初の文字のクラス名のみ変更
  textSpans[0].className = "current-letter";
  // 位置を最初に
  setPosition(0);
  // 打ち間違えた位置の配列をリセット
  setTypo(new Array(0));
};

return (
  <div className="App" onKeyPress={e => handleKey(e)} tabIndex={0}>
    <div id="textbox">
      <span className="current-letter">{text[0]}</span>
        {text
          .split("")
          .slice(1)
          .map(char => (
            <span className="waiting-letters">{char}</span>
          ))}
    </div>
    <div className="buttons">
      <MyButton onClick={typingToggle}>{typing ? "OFF" : "ON"}</MyButton>
      <MyButton onClick={refresh}>Refresh</MyButton>
    </div>
  </div>
);

これで主要な機能は完成です!

あとはテキストをランダムに生成したり、スタイルを整えたりすれば完成です。

完成形のコード全文は CodeSandbox で見られます。

Edit react-simple-typing-app

この記事が参考になったならうれしいです。

では👋