0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

React 公式チュートリアルを TypeScript + Hooks でリファクタリング

Posted at

はじめに

React を基礎から勉強する。最近の React は Hooks を用いることでクラスコンポーネントを扱わなくても開発ができるという耳障りのいい言葉を受け取った状態で React 公式チュートリアルを眺めてみると、なんとクラスコンポーネントを使っている。(チュートリアルのソースコードは数年以上更新されていないらしい。)
クラスコンポーネントなんて触りたくないとチュートリアルを敬遠していたのだが、基礎が疎かで目も当てられない状態だったので割り切って一通り試してみた。たしかにわかりやすかったが、流行りの Hooks などは使用されていないので、TypeScript + Hooks という流行りについていけるようにリファクタリングを行った。
Hooks + TypeScriptでReact公式チュートリアルをリファクタReactのチュートリアルをhooks + TypeScriptでモダンな仕様にリファクタしてみたを参考にした。

React + TypeScript 環境の導入

ローカルに開発環境を入れたくないので Docker コンテナで実行環境を作成する。概要はDocker 開発環境構築 (WSL2 + VSCode)を参照。VSCode が提供している Node.js & TypeScript のサンプルを元に作成した devcontainer.json および Dockerfile は以下。

devcontainer.json
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/typescript-node
{
	"name": "Node.js & TypeScript",
	"build": {
		"dockerfile": "Dockerfile",
		// Update 'VARIANT' to pick a Node version: 12, 14, 16
		"args": { 
			"VARIANT": "16"
		}
	},

	// Set *default* container specific settings.json values on container create.
	"settings": { 
		"editor.formatOnSave": true,
		"editor.tabSize": 2,
		"editor.wordWrap": "on",
		// "terminal.integrated.shell.linux": "/bin/bash"
	},

	// Add the IDs of extensions you want installed when the container is created.
	"extensions": [
		"formulahendry.auto-close-tag",
		"formulahendry.auto-rename-tag",
		"mrmlnc.vscode-duplicate",
		"dsznajder.es7-react-js-snippets",
		"dbaeumer.vscode-eslint",
		"eamodio.gitlens",
		"xabikos.javascriptsnippets",
		"esbenp.prettier-vscode",
		"shardulm94.trailing-spaces",
		"msjsdiag.debugger-for-chrome",
		"visualstudioexptteam.vscodeintellicode",
	],

	// Use 'forwardPorts' to make a list of ports inside the container available locally.
	// "forwardPorts": [],

	// Use 'postCreateCommand' to run commands after the container is created.
	// "postCreateCommand": "yarn install",

	// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
	"remoteUser": "node"
}
Dockerfile
ARG VARIANT="16-buster"
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}

公式チュートリアルのコード準備

上記をビルドして VSCode 上で接続する。問題なく接続できたら、VSCode 上のターミナルにて、npx create-react-app my-app --typescript を実行。作成された my-app/src 内のファイルをすべて削除して新たに index.css と index.tsx を作成し、それぞれに公式チュートリアルに載っている以下コードをコピペする。

index.css
body {
  font: 14px "Century Gothic", Futura, sans-serif;
  margin: 20px;
}

ol, ul {
  padding-left: 30px;
}

.board-row:after {
  clear: both;
  content: "";
  display: table;
}

.status {
  margin-bottom: 10px;
}

.square {
  background: #fff;
  border: 1px solid #999;
  float: left;
  font-size: 24px;
  font-weight: bold;
  line-height: 34px;
  height: 34px;
  margin-right: -1px;
  margin-top: -1px;
  padding: 0;
  text-align: center;
  width: 34px;
}

.square:focus {
  outline: none;
}

.kbd-navigation .square:focus {
  background: #ddd;
}

.game {
  display: flex;
  flex-direction: row;
}

.game-info {
  margin-left: 20px;
}
index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';

function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

class Board extends React.Component {
  renderSquare(i) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
      />
    );
  }

  render() {
    return (
      <div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [
        {
          squares: Array(9).fill(null)
        }
      ],
      stepNumber: 0,
      xIsNext: true
    };
  }

  handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? "X" : "O";
    this.setState({
      history: history.concat([
        {
          squares: squares
        }
      ]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext
    });
  }

  jumpTo(step) {
    this.setState({
      stepNumber: step,
      xIsNext: (step % 2) === 0
    });
  }

  render() {
    const history = this.state.history;
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      const desc = move ?
        'Go to move #' + move :
        'Go to game start';
      return (
        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });

    let status;
    if (winner) {
      status = "Winner: " + winner;
    } else {
      status = "Next player: " + (this.state.xIsNext ? "X" : "O");
    }

    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={i => this.handleClick(i)}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
        </div>
      </div>
    );
  }
}

// ========================================

ReactDOM.render(<Game />, document.getElementById("root"));

function calculateWinner(squares) {
  const lines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

TypeScript 形式へのリファクタリング

上記の準備を終えた状態だと、index.tsx にて多くのエラーが吐かれているので順に修正していく。

typescript, @types/react

まず yarn add typescript で typescript をインストールする。次に import React from "react"; で以下のようなエラーが出ているのを修正する。

Could not find a declaration file for module ‘react’. ...

TypeScript に対応した React となっていないことが(たぶん)原因と思われる。エラーコードに書いてあるように npm i --save-dev @types/react を実行することで解消できる。(コンテナのビルド時にあらかじめこのあたりを解消できるように Dockerfile を修正したかったが、パッとできなかったのでわかる方教えて下さい。)

リファクタリング

「はじめに」に載せたサイトを参考にリファクタリングをした。TypeScript の特徴である「静的型付け」や 「React Hooks」 の基本的な知識は、TypeScript: Documentation - Everyday TypesReact.js&Next.js超入門 第2版で事前に勉強した。

以下にリファクタリング後のコードを載せた。もともと1つのファイルで構成されていたものをコンポーネントごとに分け、以下のようなディレクトリ構成になっている。

src
├── components
│   ├── Board.tsx
│   ├── Game.tsx
│   └── Square.tsx
├── index.css
├── index.tsx
└── interface.ts

各ファイルのコードは以下。(index.css は変更していないため省略。)

index.tsx
import ReactDOM from "react-dom";
import './index.css';
import Game from "./components/Game";

ReactDOM.render(<Game />, document.getElementById("root"));
interface.ts
export type ISquare = "X" | "O" | null;
export type History = {
  squares: ISquare[];
  position: number;
};
components/Square.tsx
import React from "react";
import {ISquare} from "../interface";

interface SquareProps {
  value: ISquare;
  onClick: () => void;
}

const Square: React.FC<SquareProps> = ({value, onClick}) => {
  return (
    <button className="square" onClick={onClick}>
      {value}
    </button>
  );
}

export default Square;
components/Board.tsx
import React from "react";
import {ISquare} from "../interface";
import Square from "./Square";

interface BoardProps  {
  squares: ISquare[];
  onClick: (i: number) => void;
}

const Board: React.FC<BoardProps> = ({squares, onClick}) => {
  const renderSquare = (i: number) => {
    return (
      <Square value={squares[i]} onClick={() => 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>
  );
}

export default Board;
components/Game.tsx
import React, {useState} from "react";
import {ISquare, History} from "../interface";
import Board from "./Board";

const Game: React.FC = () => {
  const [history, setHistory] = useState<History[]>([{squares: Array(9).fill(null), position: -1}]);
  const [stepNumber, setStepNumber] = useState<number>(0);
  const [xIsNext, setXIsNext] = useState<boolean>(true);

  const handleClick = (i: number) => {
    const _history = history.slice(0, stepNumber + 1);
    const current = _history[_history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = xIsNext ? "X" : "O";
    setHistory(_history.concat([{squares: squares, position: i}]));
    setStepNumber(_history.length)
    setXIsNext(!xIsNext);
  }

  const jumpTo = (step: number) => {
    setStepNumber(step);
    setXIsNext((step % 2) === 0);
  }

  const current = history[stepNumber];
  const winner = calculateWinner(current.squares);
  const moves = history.map(({squares, position}, move) => {
    const x = position % 3 + 1;
    const y = Math.floor(position / 3) + 1;
    const desc = move ?
      'Go to move #' + move + `    (${x}, ${y})`:
      'Go to game start';
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{desc}</button>
      </li>
    );
  });

  let status;
  if (winner) {
    status = "Winner: " + winner;
  } else {
    status = "Next player: " + (xIsNext ? "X" : "O");
  }

  return (
    <div className="game">
      <div className="game-board">
        <Board
          squares={current.squares}
          onClick={(i: number) => handleClick(i)}
        />
      </div>
      <div className="game-info">
        <div>{status}</div>
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

const calculateWinner = (squares: Array<ISquare>) => {
  const lines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

export default Game;

上記で問題なく、チュートリアルアプリが動く。

おわりに

TypeScript + Hooks でのリファクタリングをした。かなり参考サイトにおんぶにだっこな状態で進めたので、自身の理解を深めるべくチュートリアル末尾に記載されている改良のアイデアを試そうと思う。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?