🐣

React公式チュートリアルをTypeScriptで(Hooks導入以後)

2021/03/23に公開

はじめに

React公式には知識ゼロからReactのコンセプトを学べる、非常に丁寧なチュートリアルが用意されています(日本語版あり)。

https://ja.reactjs.org/tutorial/tutorial.html

右も左もわからない状態からReactを学ぶことになったとき、このチュートリアルには大いに助けられました。ただしこのチュートリアルは型のない素のJavaScriptで書かれています。

ここにTypeScriptを導入するとパワフルなサジェスト機能が使えるようになり、コードがより見通しの良いものになります。

Reactでは、コンポーネントと呼ばれる機能単位をブロックとして組み合わせることが基本となります。Reactにおける型推論の恩恵の一つは、コンポーネントが要求するパラメーター(しばしば"props"という名のオブジェクトとしてまとめられます)が何であるかコード上でマニュアル化され、誤っている場合には実行前に検知できることです。

さらに、Reactにはバージョン16.8からHooksと呼ばれる機能が追加されました。

https://reactjs.org/docs/hooks-intro.html

従来、「状態」を持つ実体は、クラスとそのプロパティーによって定義されていました。Hooksを利用すると、これまでクラスでしか書けなかったコンポーネントを関数として定義できるようになります。全てが関数になると、状態-画面間の関係の理解が容易になります。また、一般にコード量も減って簡潔になります。

本記事では、

  • TypeScriptを使って
  • Hooks導入以後

のReactによってチュートリアルを解くとどうなるか概観します。コードの全体は以下のリポジトリに上げています。

https://github.com/roiban1344/react-tutorial-typescript-hooks/blob/main/src/index.tsx

なお、本記事を書くにあたって、以下の記事を大いに参考にさせて頂きました。

https://qiita.com/m0a/items/d723259cdeebe382b5f6

重複する部分も少なからずありますが、それも含めて記述することにします。

前提

本記事では公式チュートリアル同様の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

続けて、

  1. src/以下のファイルを削除
  2. src/下にindex.tsxという名前のファイルを作成し、index.jsの内容をそこへコピー。拡張子に注意してください。.tsxは、JSX(HTML要素ライクな機能単位を記述するためのJavaScriptの拡張構文)を含むTypeScriptファイルの拡張子です。
  3. index.csssrc/下にコピー
  4. index.tsxに以下の内容をコピー
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の型を明示してやりましょう。

index.tsx
  renderSquare(i: number) {

この修正後リロードすると、「空の三目並べの盤面」が表示され、スタート地点に立てます。

TypeScriptで型付け

完成するコードでは以下の3つのコンポーネントが定義されます。

  • Square:
    単一のマスの表示を担うコンポーネントです。空白・O・Xの3つの状態を取ります。

  • Board:
    3x3の9つのマスの表示を担うコンポーネントです。propsから受け取る配列の情報をもとに、9つのSquareたちに盤面の状態を表示させます。

  • Game
    ゲーム全体の進行を制御するコンポーネントです。「盤面の状態」はこのコンポーネントが管理します。また、操作に応じて過去の手番への巻き戻しも行います。

それぞれTypeScriptで書くとどうなるか見ていきましょう。

Squareコンポーネント

上述の通り、各マスは3つの状態だけを取ります。これを3つの状態の合併型として型レベルで縛りましょう。

index.tsx
type SquareState = 'O' | 'X' | null

nullが空白に対応します。これを使って、プロパティーの型を定義しましょう。

index.tsx
type SquareProps = {
    value: SquareState
    onClick: () => void
}

valueが先に定義したマスの状態です。onClickは上位のコンポーネント(今の場合Board)から受け渡されることを前提とするアロー関数です。

余談になりますが、サイズは小さいながらこの構成は象徴的です。Reactでコードを書くとき、状態の「上位の」コンポーネントへの移管がよく行われます。ここでいう「上位」とは、内部で他のコンポーネントを操作することを指しています。このリファクタリング手順はチュートリアル中でリフトアップの語で表現されています。

下位のコンポーネントは独自に操作できる状態をもたず、上位のコンポーネントから受け渡されるパラメーターをもとにした描画に徹することになります。しかしクリックや文字の入力などの操作を実際に受け取るのは下位のコンポーネントです。そこで、パラメーターとともに、「入力に応じて行うアクション」も上位のコンポーネントから渡すことになります。

素のHTMLの<input>タグや<button>タグをReactでも使おうとしたとき、入力が変化しないことに戸惑うことがあるかもしれません。その場合、少し考え方を変える必要があります。タグの内部では状態を持たず、onChange属性やonClick属性を介して上位のコンポーネントが保有する状態へアクセスするためのインターフェイスと考えるとよいかもしれません。

さて、Square本体を見ましょう。

index.tsx
const Square = (props: SquareProps) => (
    <button className='square' onClick={props.onClick}>
        {props.value}
    </button>
)

型は(props:SquareProps)=>JSX.Elementです。上で定義したSquarePropsを入力、JSX要素を出力とする、まさしく関数であることが一目瞭然です。

Squareコンポーネントの型
エディタ上で推論された型が表示されている。

なお、この場合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

https://www.npmjs.com/package/typescript-tuple

文頭に以下のインポート分を追加:

index.tsx
import { Repeat } from 'typescript-tuple'

ここでインポートしたRepeatを使うと、

index.tsx
type BoardState = Repeat<SquareState, 9>

と、簡潔に書けます。使い方は見ての通りですね。

Boardのプロパティーの型は

index.tsx
type BoardProps = {
    squares: BoardState
    onClick: (i: number) => void
}

となります。onClickは引数にマス目の番号を取ります。「i番目のマス」に対するアクションに対応します。

Board本体は以下のようになります。

index.tsx
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が持つ状態は「盤面の現在の状態」だけではなく、ゲーム開始からの最新の手番までの履歴一揃いも含むことになります。

まず、「ゲームの特定の段階」の状態に型を付けましょう。必要なのは、盤面の状態(どのマスに何が書かれているか・何も書かれていないか)と、次のプレイヤーがどちらかという情報です。

index.tsx
type Step = {
    squares: BoardState
    xIsNext: boolean
}

なお、チュートリアルの見本ではxIsNextはターンの偶奇から算出していますが、ここでは履歴に保存することにしました。

ゲームの状態は、履歴=Stepの配列と、現在表示している盤面が最初から数えて何番目かという情報です。

index.tsx
type GameState = {
    readonly history: Step[]
    readonly stepNumber: number
}

後述のuseStateを使うため、プロパティーは直接の書き換えを禁じるreadonly属性を付与します。

Game本体はプロパティーを受け取らないため、引数無しのアロー関数として定義します。

index.tsx
const Game = () => {
	/*(略)*/
}

さて、この関数コンポーネント内部で状態を操作するため、ここでついにHooksが登場します。Hooksに用意されている関数の一つ、useStateを使いましょう。まずはインポート:

index.tsx
import { useState } from 'react'

そして、Gameの内部で

index.tsx
    const [state, setState] = useState<GameState>({
        history: [
            {
                squares: [null, null, null, null, null, null, null, null, null],
                xIsNext: true,
            },
        ],
        stepNumber: 0,
    })

と書きます。squaresnull9個をそのまま書いていますが、まあ許容範囲でしょう。タプル型で定義した恩恵で、ここで要素の個数を間違えると警告してくれます。

要素が不足していると型エラーになる。
要素が不足していると型エラーになる。

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上ではHooksの変数名が見えない
DevTools上ではhistorystepNumberの変数名が表示されない。

今回のような場合には明らかに形が違うのでどちらがどちらか区別がつきますが、複数のnumber型が登場したりすると見分けがつかなくなってしまいます。一つの解決策は、単なる変数ではなく、キーを一つだけもつオブジェクトとして定義することです。

const [{ history }, setHistory] = useState<{hisotry: Step[]}>({history: /*historyの初期値*/})
const [{ stepNumber }, setStepNumber] = useState({ stepNumber: /*stepNumberの初期値*/ })

これでも十分ですが、全てをまとめたstateに特別に型を付け、useStateの使用を一回切りに留めるほうが好ましいでしょう。

stateをまとめた場合。ふたつのキーが表示されている。
stateをまとめた場合。ふたつのキーが表示されている。

参考:reactjs - Is there any way to see names of 'fields' in React multiple state with React DevTools when using 'useState' hooks - Stack Overflow

stateは普通の変数と同様に参照できます。表示する盤面の状態を指すcurrent

index.tsx
    const current = state.history[state.stepNumber]

stateから与えることができます。

この変数が状態として定義されたことの意義は、値の更新ごとにコンポーネントが再描画される点にあります。Gameコンポーネントの返り値は

index.tsx
    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の末尾を指すように更新する

ことです。

index.tsx
    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は省略できます。

index.tsx
    const jumpTo = (move: number) => {
        setState(prev => ({
            ...prev,
            stepNumber: move,
        }))
    }

ここで

state.stepNumer = move

のようにsetStateを介さない書き換えを試みると、stepNumberreadonly属性を付与しているためエラーになります。状態の更新の方法を制限することは意図しない書き換えを回避に繋がり、コードの安全性の向上に寄与します。

コードの残りの部分では勝者の計算や、描画の命令本体が書かれていますが、素のJavaScriptのコードからの大きな変更はありません。詳細はソースを参照してください。

結び

お決まりの表現ですが、フロントエンドの技術は目まぐるしく発展しています。React+TypeScriptを学ぶ中で戸惑うのは、何よりその変化の激しさです。Hooksを知ったきっかけも、クラスによる状態の管理が(非推奨とは言わないまでも)古い方法になっていることを指摘されたことでした。

この記事も遠からず古いものになるでしょう(もし公開された時点で既にそうなってしまっていればご教授いただければ幸いです)。当時の私と同じスタートラインに立っている人が「今ならこう書ける」ということを知るための一助になれば嬉しく思います。

Discussion