React公式チュートリアルをTypeScriptで(Hooks導入以後)
はじめに
React公式には知識ゼロからReactのコンセプトを学べる、非常に丁寧なチュートリアルが用意されています(日本語版あり)。
右も左もわからない状態からReactを学ぶことになったとき、このチュートリアルには大いに助けられました。ただしこのチュートリアルは型のない素のJavaScriptで書かれています。
ここにTypeScriptを導入するとパワフルなサジェスト機能が使えるようになり、コードがより見通しの良いものになります。
Reactでは、コンポーネントと呼ばれる機能単位をブロックとして組み合わせることが基本となります。Reactにおける型推論の恩恵の一つは、コンポーネントが要求するパラメーター(しばしば"props"という名のオブジェクトとしてまとめられます)が何であるかコード上でマニュアル化され、誤っている場合には実行前に検知できることです。
さらに、Reactにはバージョン16.8からHooksと呼ばれる機能が追加されました。
従来、「状態」を持つ実体は、クラスとそのプロパティーによって定義されていました。Hooksを利用すると、これまでクラスでしか書けなかったコンポーネントを関数として定義できるようになります。全てが関数になると、状態-画面間の関係の理解が容易になります。また、一般にコード量も減って簡潔になります。
本記事では、
- TypeScriptを使って
- Hooks導入以後
のReactによってチュートリアルを解くとどうなるか概観します。コードの全体は以下のリポジトリに上げています。
なお、本記事を書くにあたって、以下の記事を大いに参考にさせて頂きました。
重複する部分も少なからずありますが、それも含めて記述することにします。
前提
本記事では公式チュートリアル同様のstep-by-stepの説明は行わないため、もしReactに初めて触れられる場合、公式チュートリアルのほうを一通り解くことをお薦めします。
環境
- Node.js: v14.15.4
- npm: 6.14.10
- React.js: 17.0.1
- TypeScript: 4.2.3
- Visual Studio Code: version 1.54
導入
ローカル環境でTypeScriptを使ってチュートリアルを開始する方法を簡単に述べておきます。
Reactでは、create-react-app
でプロジェクトのテンプレートを作成できます。この項の方法に従って用意されるのは素のJavaScriptによるテンプレートですが、これをTypeScriptに変える方法は簡単です。オプション --template typescript
を追加するだけです。
npx create-react-app my-app --template typescript
続けて、
-
src/
以下のファイルを削除 -
src/
下にindex.tsx
という名前のファイルを作成し、index.jsの内容をそこへコピー。拡張子に注意してください。.tsx
は、JSX(HTML要素ライクな機能単位を記述するためのJavaScriptの拡張構文)を含むTypeScriptファイルの拡張子です。 -
index.cssを
src/
下にコピー -
index.tsx
に以下の内容をコピー
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
ここまでの作業内容でnpm start
を行うと以下のエラーが出るはずです。
Parameter 'i' implicitly has an 'any' type. TS7006
10 |
11 | class Board extends React.Component {
> 12 | renderSquare(i) {
| ^
13 | return <Square />
14 | }
15 |
This error occurred during the build time and cannot be dismissed.
指摘されているパラメーターi
の型を明示してやりましょう。
renderSquare(i: number) {
この修正後リロードすると、「空の三目並べの盤面」が表示され、スタート地点に立てます。
TypeScriptで型付け
完成するコードでは以下の3つのコンポーネントが定義されます。
-
Square
:
単一のマスの表示を担うコンポーネントです。空白・O・Xの3つの状態を取ります。 -
Board
:
3x3の9つのマスの表示を担うコンポーネントです。props
から受け取る配列の情報をもとに、9つのSquare
たちに盤面の状態を表示させます。 -
Game
ゲーム全体の進行を制御するコンポーネントです。「盤面の状態」はこのコンポーネントが管理します。また、操作に応じて過去の手番への巻き戻しも行います。
それぞれTypeScriptで書くとどうなるか見ていきましょう。
Squareコンポーネント
上述の通り、各マスは3つの状態だけを取ります。これを3つの状態の合併型として型レベルで縛りましょう。
type SquareState = 'O' | 'X' | null
null
が空白に対応します。これを使って、プロパティーの型を定義しましょう。
type SquareProps = {
value: SquareState
onClick: () => void
}
value
が先に定義したマスの状態です。onClick
は上位のコンポーネント(今の場合Board
)から受け渡されることを前提とするアロー関数です。
余談になりますが、サイズは小さいながらこの構成は象徴的です。Reactでコードを書くとき、状態の「上位の」コンポーネントへの移管がよく行われます。ここでいう「上位」とは、内部で他のコンポーネントを操作することを指しています。このリファクタリング手順はチュートリアル中でリフトアップの語で表現されています。
下位のコンポーネントは独自に操作できる状態をもたず、上位のコンポーネントから受け渡されるパラメーターをもとにした描画に徹することになります。しかしクリックや文字の入力などの操作を実際に受け取るのは下位のコンポーネントです。そこで、パラメーターとともに、「入力に応じて行うアクション」も上位のコンポーネントから渡すことになります。
素のHTMLの<input>
タグや<button>
タグをReactでも使おうとしたとき、入力が変化しないことに戸惑うことがあるかもしれません。その場合、少し考え方を変える必要があります。タグの内部では状態を持たず、onChange
属性やonClick
属性を介して上位のコンポーネントが保有する状態へアクセスするためのインターフェイスと考えるとよいかもしれません。
さて、Square
本体を見ましょう。
const Square = (props: SquareProps) => (
<button className='square' onClick={props.onClick}>
{props.value}
</button>
)
型は(props:SquareProps)=>JSX.Element
です。上で定義したSquareProps
を入力、JSX要素を出力とする、まさしく関数であることが一目瞭然です。
エディタ上で推論された型が表示されている。
なお、この場合return
は不要です。<button>~</button>
がひとまとまりの返り値、JSX要素とみなされるためです。
FC型の使用について
Reactには、FunctionComponent
、またはそのショートハンドであるFC
型という、文字通り関数コンポーネントのための型が用意されています。react
パッケージ(に紐づく@types/react
パッケージ)からインポートすることでこれを利用できます。
import { FC } from 'react'
ジェネリクスの型引数に引数の型を指定することで、たとえばSquare
コンポーネントは以下のようにも書くことができます。
const Square: FC<SquareProps> = props => (
関数コンポーネントであることを明示できて一見好ましく思われますが、現在FC
型の利用は非推奨とされています。というのも、子要素に対応するchildren
というプロパティーを暗黙的に含んでいるためです。この欠点を解消した代替手段として、VoidFunctionComponent
型、またはVFC
型が用意されています。
実のところ、そもそもここで明示的に型付けする必要はありません。上で見たように、引数の型さえ指定してやれば自動で推論が行われるためです。
参考:
Boardコンポーネント
Boardコンポーネントの状態は、「9つのマス全ての状態」です。これに型を付けましょう。配列を使って
type BoardState = SquareState[]
とすれば実現できますが、これには改善の余地があります。なぜなら、これによって指定されるのは、「任意個数のSquareState型の要素の配列」であるためです。長さ9以外の場合はありえないため、型レベルで制限を与えることが望ましいでしょう。
幸いにも、TypeScriptにはタプル型という固定長の配列の型が用意されています。従って、次によい解決策はこうです。
type BoardState = [SquareState, SquareState, SquareState, SquareState, SquareState, SquareState, SquareState, SquareState, SquareState]
......よほどウィンドウを広くしない限り、水平スクロールバーが出現していることでしょう。タプル関連のTypeScript組み込みの機能はやや痒いところに手が届かない面があります。そこでtypescript-tuple
パッケージをインストールして使うことにしました。
npm i typescript-tuple
文頭に以下のインポート分を追加:
import { Repeat } from 'typescript-tuple'
ここでインポートしたRepeat
を使うと、
type BoardState = Repeat<SquareState, 9>
と、簡潔に書けます。使い方は見ての通りですね。
Board
のプロパティーの型は
type BoardProps = {
squares: BoardState
onClick: (i: number) => void
}
となります。onClick
は引数にマス目の番号を取ります。「i
番目のマス」に対するアクションに対応します。
Board
本体は以下のようになります。
const Board = (props: BoardProps) => {
const renderSquare = (i: number) => (
<Square value={props.squares[i]} onClick={() => props.onClick(i)} />
)
return (
<div>
<div className='board-row'>
{renderSquare(0)}
{renderSquare(1)}
{renderSquare(2)}
</div>
<div className='board-row'>
{renderSquare(3)}
{renderSquare(4)}
{renderSquare(5)}
</div>
<div className='board-row'>
{renderSquare(6)}
{renderSquare(7)}
{renderSquare(8)}
</div>
</div>
)
}
チュートリアルの見本と比べると、Board
要素がクラスから関数コンポーネントになるとともに、クラスメソッドであったrenderSquare
がアロー関数になっています。これによってthis
が姿を消すことになります。ちょっと嬉しいですね(もっとも、これはTypeScriptで書くことで実現できたことではありませんが)。
Gameコンポーネント
最上位のコンポーネントであるGame
は、「盤面の現在の状態」をプレイヤーのアクションに応じて更新するとともに、過去の盤面への巻き戻しも担います。従って、Game
が持つ状態は「盤面の現在の状態」だけではなく、ゲーム開始からの最新の手番までの履歴一揃いも含むことになります。
まず、「ゲームの特定の段階」の状態に型を付けましょう。必要なのは、盤面の状態(どのマスに何が書かれているか・何も書かれていないか)と、次のプレイヤーがどちらかという情報です。
type Step = {
squares: BoardState
xIsNext: boolean
}
なお、チュートリアルの見本ではxIsNext
はターンの偶奇から算出していますが、ここでは履歴に保存することにしました。
ゲームの状態は、履歴=Step
の配列と、現在表示している盤面が最初から数えて何番目かという情報です。
type GameState = {
readonly history: Step[]
readonly stepNumber: number
}
後述のuseState
を使うため、プロパティーは直接の書き換えを禁じるreadonly
属性を付与します。
Game
本体はプロパティーを受け取らないため、引数無しのアロー関数として定義します。
const Game = () => {
/*(略)*/
}
さて、この関数コンポーネント内部で状態を操作するため、ここでついにHooksが登場します。Hooksに用意されている関数の一つ、useState
を使いましょう。まずはインポート:
import { useState } from 'react'
そして、Game
の内部で
const [state, setState] = useState<GameState>({
history: [
{
squares: [null, null, null, null, null, null, null, null, null],
xIsNext: true,
},
],
stepNumber: 0,
})
と書きます。squares
はnull
9個をそのまま書いていますが、まあ許容範囲でしょう。タプル型で定義した恩恵で、ここで要素の個数を間違えると警告してくれます。
要素が不足していると型エラーになる。
useState
の基本的な用法は、
const [state, setState] = useState<TypeOfState>(initialState)
です。ここで登場した変数はそれぞれ、
-
state
:
状態そのものを指す変数。TypeOfState
型。 -
setState
:
stateを操作するために生成される関数。React.Dispatch<React.SetStateAction<TypeOfState>>
型。 -
TypeOfState
:
状態の型 -
initialState
:
初期状態。TypeOfState
型。
となっています。
stateを一つにまとめることについて
stateは一つにまとめず、独立にuseStateで宣言することもできます。
const [history, setHistory] = useState<Step[]>(/*historyの初期値*/)
const [stepNumber, setStepNumber] = useState(/*stepNumberの初期値*/)
一見問題なさそうなこの方法の難点は、Chrome拡張として用意されているReactの開発者ツール上で変数名が表示されず、一様にただState
として扱われてしまうことです。
DevTools上ではhistory
やstepNumber
の変数名が表示されない。
今回のような場合には明らかに形が違うのでどちらがどちらか区別がつきますが、複数のnumber型が登場したりすると見分けがつかなくなってしまいます。一つの解決策は、単なる変数ではなく、キーを一つだけもつオブジェクトとして定義することです。
const [{ history }, setHistory] = useState<{hisotry: Step[]}>({history: /*historyの初期値*/})
const [{ stepNumber }, setStepNumber] = useState({ stepNumber: /*stepNumberの初期値*/ })
これでも十分ですが、全てをまとめたstate
に特別に型を付け、useState
の使用を一回切りに留めるほうが好ましいでしょう。
stateをまとめた場合。ふたつのキーが表示されている。
state
は普通の変数と同様に参照できます。表示する盤面の状態を指すcurrent
は
const current = state.history[state.stepNumber]
とstate
から与えることができます。
この変数が状態として定義されたことの意義は、値の更新ごとにコンポーネントが再描画される点にあります。Game
コンポーネントの返り値は
return (
<div className='game'>
<div className='game-board'>
<Board squares={current.squares} onClick={handleClick} />
</div>
<div className='game-info'>
<div>{status}</div>
<ol>{moves}</ol>
</div>
</div>
)
です。Board
コンポーネントにcurrent.squares
を渡しています。プレイヤーのアクションに応じてcurrent
が更新されるたび、Game
は新しい値を返すのです。
setState
は引数に、「直前の状態」から「次の状態」を計算するコールバック関数をとります。実行ごとに、状態は計算された新しい状態に更新されます。
setState
の用例を見るため、マスi
がクリックされたときの処理を記述するhandleClick
関数に注目しましょう。基本的な動作は、
- マス
i
にOかXを書きこんだ新しい盤面(next
)をhistory
の末尾に加え、 -
stepNumber
がhisotryの末尾を指すように更新する
ことです。
const handleClick = (i: number) => {
/* 中略 次の盤面の状態=nextを定義*/
setState(({ history, stepNumber }) => { //{ hisotry, stepNumber }は直前の状態。
//更新された履歴。sliceでミューテートを回避していることに注意。
const newHistory = history.slice(0, stepNumber + 1).concat(next)
//stateは以下の値に更新される。
return {
history: newHistory,
stepNumber: newHistory.length - 1,
}
})
}
また、Game
には表示する盤面の状態を巻き戻すjumpTo
関数が定義されています。ここで更新したい値はstepNumber
だけですが、以下のようにスプレッド構文を使うと更新されないhistory
は省略できます。
const jumpTo = (move: number) => {
setState(prev => ({
...prev,
stepNumber: move,
}))
}
ここで
state.stepNumer = move
のようにsetState
を介さない書き換えを試みると、stepNumber
にreadonly
属性を付与しているためエラーになります。状態の更新の方法を制限することは意図しない書き換えを回避に繋がり、コードの安全性の向上に寄与します。
コードの残りの部分では勝者の計算や、描画の命令本体が書かれていますが、素のJavaScriptのコードからの大きな変更はありません。詳細はソースを参照してください。
結び
お決まりの表現ですが、フロントエンドの技術は目まぐるしく発展しています。React+TypeScriptを学ぶ中で戸惑うのは、何よりその変化の激しさです。Hooksを知ったきっかけも、クラスによる状態の管理が(非推奨とは言わないまでも)古い方法になっていることを指摘されたことでした。
この記事も遠からず古いものになるでしょう(もし公開された時点で既にそうなってしまっていればご教授いただければ幸いです)。当時の私と同じスタートラインに立っている人が「今ならこう書ける」ということを知るための一助になれば嬉しく思います。
Discussion