LoginSignup
5
3

More than 3 years have passed since last update.

React公式チュートリアル『改良のアイディア』を実装してみた

Last updated at Posted at 2020-03-18

前書き

SPAやってみたいなあと思っていたので、以下の記事を読んでReactを初めてみました。
ReactとVueのどちらを選ぶか - Qiita
https://qiita.com/teradonburi/items/fb91e5feacab5071cfef - Qiita
◆ Spring Bootで作成したAPIをReactからGETする - Qiita

最初にやってみるのはやはり公式ということで、チュートリアル:React の導入にチャレンジ。
基本的なpropsstateの考え方が分かりやすく、リファクタリング手順も載っているのでかなり見返すことになりそうです。

チュートリアルの最後に以下のような記載がありましたので、こちらにもチャレンジ。

時間がある場合や、今回身につけた新しいスキルを練習してみたい場合に、あなたが挑戦できる改良のアイデアを以下にリストアップしています。

見返した自分の役に立つようチャレンジ時に考えていたことも書いているので、
参考になれば幸いです。

実装

コードは最終手順からのステップアップ形式でやっていきます。

1. 履歴内のそれぞれの着手の位置を (col, row) というフォーマットで表示する。

イメージはこんな感じ。
react-tutorial-improve-1.png

まず、変更したいのは以下の部分です。

jsx
// 省略
class Game extends React.Component {
  constructor(props) {
    // 省略
  }

  handleClick(i) {
    // 省略
  }

  jumpTo(step) {
    // 省略
  }

  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 (
        // 省略
      );
    });
    // 省略
  }
}
// 省略

この部分で以下のように表示できれば。。。

jsx
      const desc = move ?
        'Go to move #' + move + '(' + col + ', ' + row + ')' :
        'Go to game start';

では、colrowはどのように求めるか。
1マスを表すSquareコンポーネントで、自身の座標を持つのが良さそうかな?と最初は考えました。
しかし、State のリフトアップで行ったように、

Board が各 Square に、現時点の state がどうなっているか問い合わせればよいだけでは、と思うかもしれません。
React でそれをすることも可能ですが、コードが分かりにくく、より壊れやすく、リファクタリングしづらいものになるのでお勧めしません。

とあります。
実際にSquareコンポーネント(とその親のBoardコンポーネント)はステートレスの状態になっているので、
Gameコンポーネントで管理することになりそうです。

ではまず、Squareコンポーネントを生成している部分を見ていきます。
このプログラム内でクリックした場所を表すのは、以下の部分です。

jsx
// 省略
class Board extends React.Component {
  renderSquare(i) {  // ← 2. ここの引数として渡され
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}  // ← 3. ここでGameコンポーネントから受け取った関数に渡される
      />
    );
  }

  render() {
    return (
      <div>
        <div className="board-row">
          {this.renderSquare(0)}  // ← 1. ここでべた書きされているセル番号が
          {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>
    );
  }
}
// 省略

つまり、最終的にGameコンポーネントから渡す関数オブジェクトに渡されるわけですね。
この関数オブジェクトは以下のように定義されています。

jsx
// 省略
class Game extends React.Component {
  // 省略
  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
    });
  }

  // 省略

  render() {
    // 省略
    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={i => this.handleClick(i)} // ← ここでpropsとして渡されている
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
        </div>
      </div>
    );
  }
}

ですので、handleClickメソッド内部でstateとして保持してあげれば良さそうです。
現状stateとして保持されている情報は以下の3つです。

  • history: squaresをプロパティとして持つObjectの配列
  • stepNumber: いま何手目の状態を見ているのかを表す数字
  • xIsNext: 次がXの手番であるかどうかを表す真偽値

保持したい情報は各手番で押されたセルの座標ですので、historyと1対1の関係ですね。
ここで、historyが「Objectの配列」であるということが大切になります。
実際、私がチュートリアルをやっていたときは「historyってオブジェクトでラップせずに配列の配列でよくね」と考えていましたが、
ここまで手を進めて拡張性のためにわざわざしていたのだと気付きました。(違ったらすみません。。。)
ということで、historyの配列内オブジェクトにプロパティを追加します。

jsx
// 省略
class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [
        {
-         squares: Array(9).fill(null)
+         squares: Array(9).fill(null),
+         location: {
+           col: null,
+           row: null,
+         },
        }
      ],
      stepNumber: 0,
      xIsNext: true
    };
  }
  // 省略
}

初期値はnullですが、handleClickメソッド内で値を設定します。

col0~8のセル番号を3で割った余りにすればよいでしょう。
コードで表すと、i % 3となります。

row0~8のセル番号を3で割った商にすればよいでしょう。
コードで表すと、Math.trunc(i / 3)となります。
Math.floorでも良いという記事もあります。
今回はiが0を含む自然数ですので問題にはなりませんが、Math.truncの方が定義としては正しいでしょう。
Math.trunc(-6.5 / 3) -> -2Math.floor(-6.5 / 3) -> -3という違いがあります。

jsx
// 省略
class Game extends React.Component {
  // 省略
  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
+         squares: squares,
+         location: {
+           col: i % 3,
+           row: Math.trunc(i / 3),
+         },
        }
      ]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext
    });
  }
  // 省略
}

座標を保持できたので、ボタンに表示します。

jsx
// 省略
class Game extends React.Component {
  // 省略
  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 move #' + move + '(' + step.location.col + ', ' + step.location.row + ')' :
        'Go to game start';
      return (
        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });
    // 省略
  }
}
// 省略

これで完成です。

【クリックで展開】ここまでのコードは以下になります。(CodePenはこちら
html(変更なし)
<div id="errors" style="
  background: #c00;
  color: #fff;
  display: none;
  margin: -20px -20px 20px;
  padding: 20px;
  white-space: pre-wrap;
"></div>
<div id="root"></div>
<script>
window.addEventListener('mousedown', function(e) {
  document.body.classList.add('mouse-navigation');
  document.body.classList.remove('kbd-navigation');
});
window.addEventListener('keydown', function(e) {
  if (e.keyCode === 9) {
    document.body.classList.add('kbd-navigation');
    document.body.classList.remove('mouse-navigation');
  }
});
window.addEventListener('click', function(e) {
  if (e.target.tagName === 'A' && e.target.getAttribute('href') === '#') {
    e.preventDefault();
  }
});
window.onerror = function(message, source, line, col, error) {
  var text = error ? error.stack || error : message + ' (at ' + source + ':' + line + ':' + col + ')';
  errors.textContent += text + '\n';
  errors.style.display = '';
};
console.error = (function(old) {
  return function error() {
    errors.textContent += Array.prototype.slice.call(arguments).join(' ') + '\n';
    errors.style.display = '';
    old.apply(this, arguments);
  }
})(console.error);
</script>
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;
}
jsx
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),
          location: {
            col: null,
            row: 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,
          location: {
            col: i % 3,
            row: Math.trunc(i / 3),
          },
        }
      ]),
      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 + '(' + step.location.col + ', ' + step.location.row + ')' :
        '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;
}

2. 着手履歴のリスト中で現在選択されているアイテムをボールドにする。

ボタンのテキストをfont-weight: boldにします。
イメージはこんな感じ。
react-tutorial-improve-2.png

なんか1.より2.の方が簡単な気もしますが。。。
まずCSSにboldのスタイルを作成します。

css
/* 省略 */
.text-bold {
  font-weight: bold;
}

このスタイルをどのように当てるかですが、チュートリアル内で紹介されている一段階ずつ学べるガイドの項目の中に2. JSX の導入というものがあります。
これによると、

あらゆる有効な JavaScript の式を JSX 内で中括弧に囲んで使用できます。

ということなので、classNameの右辺で中括弧を使用した分岐ができれば良さそう。

jsx
// 省略
class Game extends React.Component {
  // 省略
  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 + '(' + step.location.col + ', ' + step.location.row + ')' :
        'Go to game start';
      return (
        <li key={move}>
-         <button onClick={() => this.jumpTo(move)}>{desc}</button>
+         <button
+           onClick={() => this.jumpTo(move)}
+           className={move === this.state.stepNumber ? 'text-bold' : ''}
+         >
+           {desc}
+         </button>
        </li>
      );
    });
    // 省略
  }
}
// 省略

※読みやすくするため、onClickclassNameの両プロパティをそれぞれ独立した行に配置

【クリックで展開】ここまでのコードは以下になります。(CodePenはこちら
html(変更なし)
<div id="errors" style="
  background: #c00;
  color: #fff;
  display: none;
  margin: -20px -20px 20px;
  padding: 20px;
  white-space: pre-wrap;
"></div>
<div id="root"></div>
<script>
window.addEventListener('mousedown', function(e) {
  document.body.classList.add('mouse-navigation');
  document.body.classList.remove('kbd-navigation');
});
window.addEventListener('keydown', function(e) {
  if (e.keyCode === 9) {
    document.body.classList.add('kbd-navigation');
    document.body.classList.remove('mouse-navigation');
  }
});
window.addEventListener('click', function(e) {
  if (e.target.tagName === 'A' && e.target.getAttribute('href') === '#') {
    e.preventDefault();
  }
});
window.onerror = function(message, source, line, col, error) {
  var text = error ? error.stack || error : message + ' (at ' + source + ':' + line + ':' + col + ')';
  errors.textContent += text + '\n';
  errors.style.display = '';
};
console.error = (function(old) {
  return function error() {
    errors.textContent += Array.prototype.slice.call(arguments).join(' ') + '\n';
    errors.style.display = '';
    old.apply(this, arguments);
  }
})(console.error);
</script>
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;
}

.text-bold {
  font-weight: bold;
}
jsx
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),
          location: {
            col: null,
            row: 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,
          location: {
            col: i % 3,
            row: Math.trunc(i / 3),
          },
        }
      ]),
      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 + '(' + step.location.col + ', ' + step.location.row + ')' :
        'Go to game start';
      return (
        <li key={move}>
          <button
            onClick={() => this.jumpTo(move)}
            className={move === this.state.stepNumber ? 'text-bold' : ''}
          >
            {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;
}

3. Board でマス目を並べる部分を、ハードコーディングではなく 2 つのループを使用するように書き換える。

見た目に変更はありません。
こちらもとっかかりやすいですね。
対象は以下の部分です。

jsx
// 省略
class Board extends React.Component {
  // 省略
  render() {
    const cols = [0, 1, 2];
    return (
      <div>
        <div className="board-row"> //                   ┐
          {this.renderSquare(0)}    // ┐                 │
          {this.renderSquare(1)}    // │ここがループ(1)   │
          {this.renderSquare(2)}    // ┘                 │
        </div>                      //                   │
        <div className="board-row"> //                   │
          {this.renderSquare(3)}    //                   │
          {this.renderSquare(4)}    //                   │ ここがループ(2)
          {this.renderSquare(5)}    //                   │
        </div>                      //                   │
        <div className="board-row"> //                   │
          {this.renderSquare(6)}    //                   │
          {this.renderSquare(7)}    //                   │
          {this.renderSquare(8)}    //                   │
        </div>                      //                   ┘
      </div>
    );
  }
}
// 省略

まずループ(1)からやっていきます。
JSXでは中括弧で囲まれた部分でJavascriptを使用できるので、以下のようにループします。

jsx
// 省略
class Board extends React.Component {
  // 省略
  render() {
+   const cols = [0, 1, 2];
    return (
      <div>
        <div className="board-row">
-         {this.renderSquare(0)}
-         {this.renderSquare(1)}
-         {this.renderSquare(2)}
+         {cols.map(col => this.renderSquare(col))}
        </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>
    );
  }
}
// 省略

このままではチュートリアルの「過去の着手の表示」項目でも起きたように、
以下の警告も出力されているはずです。

Warning: Each child in an array or iterator should have a unique "key" prop. Check the render method of "Board".

そのため、renderSquareメソッド内で呼び出しているSquareコンポーネントにkeyプロパティを追加します。

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

これでエラーが表示されなくなったので、他の<div className="board-row">にもループを適用します。

jsx
// 省略
class Board extends React.Component {
  // 省略
  render() {
    const cols = [0, 1, 2];
    return (
      <div>
        <div className="board-row">
          {cols.map(col => this.renderSquare(col))}
        </div>
        <div className="board-row">
-         {this.renderSquare(3)}
-         {this.renderSquare(4)}
-         {this.renderSquare(5)}
+         {cols.map(col => this.renderSquare(col + 3))}
        </div>
        <div className="board-row">
-         {this.renderSquare(6)}
-         {this.renderSquare(7)}
-         {this.renderSquare(8)}
+         {cols.map(col => this.renderSquare(col + 6))}
        </div>
      </div>
    );
  }
}
// 省略

そして、ループ(2)を実装します。

jsx
// 省略
class Board extends React.Component {
  // 省略
  render() {
+   const rows = [0, 1, 2];
    const cols = [0, 1, 2];
    return (
      <div>
-       <div className="board-row">
-         {cols.map(col => this.renderSquare(col))}
-       </div>
-       <div className="board-row">
-        {cols.map(col => this.renderSquare(col + 3))}
-       </div>
-       <div className="board-row">
-         {cols.map(col => this.renderSquare(col + 6))}
-       </div>
+       {rows.map(row => {
+         return (
+           <div className="board-row">
+             {cols.map(col => this.renderSquare(row * 3 + col))}
+           </div>
+         );
+       })}
      </div>
    );
  }
}
// 省略

2重ループは実装できましたが、また同じエラーが出ているので、rowでループしている<div className="board-row">にもkeyプロパティを追加します。

jsx
// 省略
class Board extends React.Component {
  // 省略
  render() {
    const rows = [0, 1, 2];
    const cols = [0, 1, 2];
    return (
      <div>
        {rows.map(row => {
          return (
-           <div className="board-row">
+           <div
+             className="board-row"
+             key={row}
+           >
              {cols.map(col => this.renderSquare(row * 3 + col))}
            </div>
          );
        })}
      </div>
    );
  }
}
// 省略

※読みやすくするため、classNamekeyの両プロパティをそれぞれ独立した行に配置

【クリックで展開】ここまでのコードは以下になります。(CodePenはこちら
html(変更なし)
<div id="errors" style="
  background: #c00;
  color: #fff;
  display: none;
  margin: -20px -20px 20px;
  padding: 20px;
  white-space: pre-wrap;
"></div>
<div id="root"></div>
<script>
window.addEventListener('mousedown', function(e) {
  document.body.classList.add('mouse-navigation');
  document.body.classList.remove('kbd-navigation');
});
window.addEventListener('keydown', function(e) {
  if (e.keyCode === 9) {
    document.body.classList.add('kbd-navigation');
    document.body.classList.remove('mouse-navigation');
  }
});
window.addEventListener('click', function(e) {
  if (e.target.tagName === 'A' && e.target.getAttribute('href') === '#') {
    e.preventDefault();
  }
});
window.onerror = function(message, source, line, col, error) {
  var text = error ? error.stack || error : message + ' (at ' + source + ':' + line + ':' + col + ')';
  errors.textContent += text + '\n';
  errors.style.display = '';
};
console.error = (function(old) {
  return function error() {
    errors.textContent += Array.prototype.slice.call(arguments).join(' ') + '\n';
    errors.style.display = '';
    old.apply(this, arguments);
  }
})(console.error);
</script>
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;
}

.text-bold {
  font-weight: bold;
}
jsx
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)}
        key={i}
      />
    );
  }

  render() {
    const rows = [0, 1, 2];
    const cols = [0, 1, 2];
    return (
      <div>
        {rows.map(row => {
          return (
            <div
              className="board-row"
              key={row}
            >
              {cols.map(col => this.renderSquare(row * 3 + col))}
            </div>
          );
        })}
      </div>
    );
  }
}

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [
        {
          squares: Array(9).fill(null),
          location: {
            col: null,
            row: 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,
          location: {
            col: i % 3,
            row: Math.trunc(i / 3),
          },
        }
      ]),
      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 + '(' + step.location.col + ', ' + step.location.row + ')' :
        'Go to game start';
      return (
        <li key={move}>
          <button
            onClick={() => this.jumpTo(move)}
            className={move === this.state.stepNumber ? 'text-bold' : ''}
          >
            {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;
}

4. 着手履歴のリストを昇順・降順いずれでも並べかえられるよう、トグルボタンを追加する。

イメージはこんな感じ。
react-tutorial-improve-4.png

こちらは一見簡単そうに見えます。
具体的には、「Reverse history order」のボタンを用意し、クリックした際にhistoryプロパティを逆順にするreverseHistoryOrderメソッドを追加します。

jsx
// 省略
class Game extends React.Component {
  // 省略
+ reverseHistoryOrder() {
+   this.setState({
+     history: this.state.history.slice().reverse(),
+   });
+ }

  render() {
    // 省略
    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>
+         <button onClick={() => this.reverseHistoryOrder()}>Reverse history order</button>
        </div>
      </div>
    );
  }
}

ですが、この方法では以下の点で不具合が生じています。(他にもあるが割愛)

  • 「Go to game start」ボタンが常に最初に表示されている
  • 「Go to move」の番号が逆転しない
  • 逆順にした際にゲームデータがリセットされる
  • ゲームを続けた際、historyの最後にmoveが追加される

これらの不具合は、Go to ~ボタンの表示がhistory.mapのインデックス(変数名はmove)に基づいて行われているためです。
解消するには、Gameコンポーネントで現状「昇順」と「降順」のどちらで表示されているかを管理する必要があります。

まず、GameコンポーネントのstateisAscendingOrderの真偽値を追加します。

jsx
// 省略
class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [
        {
          squares: Array(9).fill(null),
          location: {
            col: null,
            row: null,
          },
        }
      ],
      stepNumber: 0,
+     isAscendingOrder: true,
      xIsNext: true
    };
  }
  // 省略
}

次に、reverseHistoryOrderメソッド内ではisAscendingOrderを反転させます。

jsx
// 省略
class Game extends React.Component {
  // 省略
  reverseHistoryOrder() {
    this.setState({
-     history: this.state.history.slice().reverse(),
+     isAscendingOrder: !this.state.isAscendingOrder,
    });
  }
  // 省略
}

これでhistoryの並び順を管理できるようになりましたが、このままでは表示が変わりません。
Gameコンポーネントのrenderメソッドを修正します。

jsx
// 省略
class Game extends React.Component {
  // 省略
  render() {
-   const history = this.state.history;
    // 逆順の場合はthis.state.history配列のコピーを反転させる
    // これにより、this.state.historyはつねに昇順のデータを保持し続ける
+   const history = this.state.isAscendingOrder ? this.state.history : this.state.history.slice().reverse();
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      // 逆順の場合はインデックスを反転させる
+     const moveIndex = this.state.isAscendingOrder ? move : history.length - 1 - move;
-     const desc = move ?
+     const desc = moveIndex ?
-       'Go to move #' + move + '(' + step.location.col + ', ' + step.location.row + ')' :
+       'Go to move #' + moveIndex + '(' + step.location.col + ', ' + step.location.row + ')' :
        'Go to game start';
      return (
-       <li key={move}>
+       <li key={moveIndex}>
          <button
-           onClick={() => this.jumpTo(move)}
+           onClick={() => this.jumpTo(moveIndex)}
            className={move === currentStepNumber ? 'text-bold' : ''}
          >
            {desc}
          </button>
        </li>
      );
    });
    // 省略
  }
}
// 省略

ポイントは(コメントにも書いていますが)this.state.history配列のコピーを反転させることです。
チュートリアルの「イミュータビリティは何故重要なのか」項目にも記載されていますが、
this.state.historyはつねに昇順のデータを保持し、あくまで表示のタイミングのみ反転した履歴を扱うことで、
GameコンポーネントのhandleClickメソッドなどが修正不要となります。

ただし、このままでは画像のように、順序を入れ替えた場合に「現在選択されているアイテム」がずれてしまうバグがあります。
react-tutorial-improve-4-bad.png
#3を選択していたのに、反転後に#1が選択されてしまっている

ですので、「現在選択されているアイテム」は降順の場合に反転させる必要があります。

jsx
// 省略
class Game extends React.Component {
  // 省略
  render() {
    // 逆順の場合はthis.state.history配列のコピーを反転させる
    // これにより、this.state.historyはつねに昇順のデータを保持し続ける
    const history = this.state.isAscendingOrder ? this.state.history : this.state.history.slice().reverse();
    // 現在選択されているアイテムのインデックスを逆順の場合に反転させる
+   const currentStepNumber = this.state.isAscendingOrder ? this.state.stepNumber : history.length - 1 - this.state.stepNumber;
-   const current = history[this.state.stepNumber];
+   const current = history[currentStepNumber];
    const winner = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      // 逆順の場合はインデックスを反転させる
      const moveIndex = this.state.isAscendingOrder ? move : history.length - 1 - move;
      const desc = moveIndex ?
        'Go to move #' + moveIndex + '(' + step.location.col + ', ' + step.location.row + ')' :
        'Go to game start';
      return (
        <li key={moveIndex}>
          <button
            onClick={() => this.jumpTo(moveIndex)}
-           className={move === this.state.stepNumber ? 'text-bold' : ''}
+           className={move === currentStepNumber ? 'text-bold' : ''}
          >
            {desc}
          </button>
        </li>
      );
    });
    // 省略
  }
}
// 省略

これで完成です。

【クリックで展開】ここまでのコードは以下になります。(CodePenはこちら
html(変更なし)
<div id="errors" style="
  background: #c00;
  color: #fff;
  display: none;
  margin: -20px -20px 20px;
  padding: 20px;
  white-space: pre-wrap;
"></div>
<div id="root"></div>
<script>
window.addEventListener('mousedown', function(e) {
  document.body.classList.add('mouse-navigation');
  document.body.classList.remove('kbd-navigation');
});
window.addEventListener('keydown', function(e) {
  if (e.keyCode === 9) {
    document.body.classList.add('kbd-navigation');
    document.body.classList.remove('mouse-navigation');
  }
});
window.addEventListener('click', function(e) {
  if (e.target.tagName === 'A' && e.target.getAttribute('href') === '#') {
    e.preventDefault();
  }
});
window.onerror = function(message, source, line, col, error) {
  var text = error ? error.stack || error : message + ' (at ' + source + ':' + line + ':' + col + ')';
  errors.textContent += text + '\n';
  errors.style.display = '';
};
console.error = (function(old) {
  return function error() {
    errors.textContent += Array.prototype.slice.call(arguments).join(' ') + '\n';
    errors.style.display = '';
    old.apply(this, arguments);
  }
})(console.error);
</script>
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;
}

.text-bold {
  font-weight: bold;
}
jsx
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)}
        key={i}
      />
    );
  }

  render() {
    const rows = [0, 1, 2];
    const cols = [0, 1, 2];
    return (
      <div>
        {rows.map(row => {
          return (
            <div
              className="board-row"
              key={row}
            >
              {cols.map(col => this.renderSquare(row * 3 + col))}
            </div>
          );
        })}
      </div>
    );
  }
}

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [
        {
          squares: Array(9).fill(null),
          location: {
            col: null,
            row: null,
          },
        }
      ],
      stepNumber: 0,
      isAscendingOrder: true,
      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,
          location: {
            col: i % 3,
            row: Math.trunc(i / 3),
          },
        }
      ]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext
    });
  }

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

  reverseHistoryOrder() {
    this.setState({
      isAscendingOrder: !this.state.isAscendingOrder,
    });
  }

  render() {
    // 逆順の場合はthis.state.history配列のコピーを反転させる
    // これにより、this.state.historyはつねに昇順のデータを保持し続ける
    const history = this.state.isAscendingOrder ? this.state.history : this.state.history.slice().reverse();
    const currentStepNumber = this.state.isAscendingOrder ? this.state.stepNumber : history.length - 1 - this.state.stepNumber;
    const current = history[currentStepNumber];
    const winner = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      // 逆順の場合はインデックスを反転させる
      const moveIndex = this.state.isAscendingOrder ? move : history.length - 1 - move;
      const desc = moveIndex ?
        'Go to move #' + moveIndex + '(' + step.location.col + ', ' + step.location.row + ')' :
        'Go to game start';
      return (
        <li key={moveIndex}>
          <button
            onClick={() => this.jumpTo(moveIndex)}
            className={move === currentStepNumber ? 'text-bold' : ''}
          >
            {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>
          <button onClick={() => this.reverseHistoryOrder()}>Reverse history order</button>
        </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;
}

5. どちらかが勝利した際に、勝利につながった3つのマス目をハイライトする。

イメージはこんな感じ。
react-tutorial-improve-5.png

こちらはまずcalculateWinner関数を修正する必要がありそうです。
現状のcalculateWinner関数は勝者(X or O or null)を返却していますが、
勝者がいる場合はそのパターンも一緒にしたオブジェクトを返却するようにしましょう。

jsx
// 省略
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 {
+       winner: squares[a],
+       causedWinCells: lines[i],
+     };
    }
  }
- return null;
+ return {
+   winner: null,
+   causedWinCells: [],
+ };
}

さらに、calculateWinner関数の呼び出し元も修正します。

jsx
// 省略
class Game extends React.Component {
  // 省略
  handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
+   const winInfo = calculateWinner(squares);
-   if (calculateWinner(squares) || squares[i]) {
+   if (winInfo.winner || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? "X" : "O";
    // 省略
  }

  // 省略
  render() {
    // 逆順の場合はthis.state.history配列のコピーを反転させる
    // これにより、this.state.historyはつねに昇順のデータを保持し続ける
    const history = this.state.isAscendingOrder ? this.state.history : this.state.history.slice().reverse();
    // 現在選択されているアイテムのインデックスを逆順の場合に反転させる
    const currentStepNumber = this.state.isAscendingOrder ? this.state.stepNumber : history.length - 1 - this.state.stepNumber;
    const current = history[currentStepNumber];
-   const winner = calculateWinner(current.squares);
+   const winInfo = calculateWinner(current.squares);
    // 省略
    let status;
-   if (winner) {
+   if (winInfo.winner) {
-     status = "Winner: " + winner;
+     status = "Winner: " + winInfo.winner;
    } else {
      status = "Next player: " + (this.state.xIsNext ? "X" : "O");
    }
    // 省略
  }
}
// 省略

これで今まで通りの表示ができるようになりました。
次はハイライト部分を作成します。
まず、ハイライトされた際に適用されるCSSを記述します。色は適当

css
/* 省略 */
.square.caused-win {
  background: #fff799;
}

最終的に<button class="square caused-win"></button>を生成するのはSquareコンポーネントですので、
真偽値が渡される前提で修正します。

jsx
function Square(props) {
  return (
-   <button className="square" onClick={props.onClick}>
+   <button
+     className={'square' + (props.causedWin ? ' caused-win' : '')}
+     onClick={props.onClick}
+   >
      {props.value}
    </button>
  );
}
// 省略

※読みやすくするため、classNameonClickの両プロパティをそれぞれ独立した行に配置
' caused-win'の部分で、最初にスペースが入ることに気を付けてください。

Squareコンポーネントの呼び出し元であるBoardコンポーネントのrenderSquareメソッドも修正します。

jsx
// 省略
class Board extends React.Component {
- renderSquare(i) {
+ renderSquare(i, causedWin) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
        key={i}
+       causedWin={causedWin}
      />
    );
  }
  // 省略
}
// 省略

BoardコンポーネントのrenderSquareメソッドはBoardコンポーネントのrenderメソッドから呼び出されており、
ここで初めてセル番号(Squareコンポーネントのkey)を生成しています。
ですので、同時にそのセルが勝利につながったかどうかを判定します。

jsx
// 省略
class Board extends React.Component {
  // 省略
  render() {
    const rows = [0, 1, 2];
    const cols = [0, 1, 2];
    return (
      <div>
        {rows.map(row => {
          return (
            <div
              className="board-row"
              key={row}
            >
-             {cols.map(col => this.renderSquare(row * 3 + col))}
+             {cols.map(col => {
+               const cell = row * 3 + col;
+               const causedWin = this.props.causedWinCells.includes(cell);
+               return this.renderSquare(cell, causedWin);
              })}
            </div>
          );
        })}
      </div>
    );
  }
}
// 省略

あとはGameコンポーネントからBoardコンポーネントにcausedWinCellspropsで渡せば完成です。

jsx
// 省略
class Game extends React.Component {
  // 省略
  render() {
    // 省略
    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={i => this.handleClick(i)}
+           causedWinCells={winInfo.causedWinCells}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
          <button onClick={() => this.reverseHistoryOrder()}>Reverse history order</button>
        </div>
      </div>
    );
  }
}
// 省略

【クリックで展開】ここまでのコードは以下になります。(CodePenはこちら
html(変更なし)
<div id="errors" style="
  background: #c00;
  color: #fff;
  display: none;
  margin: -20px -20px 20px;
  padding: 20px;
  white-space: pre-wrap;
"></div>
<div id="root"></div>
<script>
window.addEventListener('mousedown', function(e) {
  document.body.classList.add('mouse-navigation');
  document.body.classList.remove('kbd-navigation');
});
window.addEventListener('keydown', function(e) {
  if (e.keyCode === 9) {
    document.body.classList.add('kbd-navigation');
    document.body.classList.remove('mouse-navigation');
  }
});
window.addEventListener('click', function(e) {
  if (e.target.tagName === 'A' && e.target.getAttribute('href') === '#') {
    e.preventDefault();
  }
});
window.onerror = function(message, source, line, col, error) {
  var text = error ? error.stack || error : message + ' (at ' + source + ':' + line + ':' + col + ')';
  errors.textContent += text + '\n';
  errors.style.display = '';
};
console.error = (function(old) {
  return function error() {
    errors.textContent += Array.prototype.slice.call(arguments).join(' ') + '\n';
    errors.style.display = '';
    old.apply(this, arguments);
  }
})(console.error);
</script>
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;
}

.text-bold {
  font-weight: bold;
}

.square.caused-win {
  background: #fff799;
}
jsx
function Square(props) {
  return (
    <button
      className={'square' + (props.causedWin ? ' caused-win' : '')}
      onClick={props.onClick}
    >
      {props.value}
    </button>
  );
}

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

  render() {
    const rows = [0, 1, 2];
    const cols = [0, 1, 2];
    return (
      <div>
        {rows.map(row => {
          return (
            <div
              className="board-row"
              key={row}
            >
              {cols.map(col => {
                const cell = row * 3 + col;
                const causedWin = this.props.causedWinCells.includes(cell);
                return this.renderSquare(cell, causedWin);
              })}
            </div>
          );
        })}
      </div>
    );
  }
}

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [
        {
          squares: Array(9).fill(null),
          location: {
            col: null,
            row: null,
          },
        }
      ],
      stepNumber: 0,
      isAscendingOrder: true,
      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();
    const winInfo = calculateWinner(squares);
    if (winInfo.winner || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? "X" : "O";
    this.setState({
      history: history.concat([
        {
          squares: squares,
          location: {
            col: i % 3,
            row: Math.trunc(i / 3),
          },
        }
      ]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext
    });
  }

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

  reverseHistoryOrder() {
    this.setState({
      isAscendingOrder: !this.state.isAscendingOrder,
    });
  }

  render() {
    // 逆順の場合はthis.state.history配列のコピーを反転させる
    // これにより、this.state.historyはつねに昇順のデータを保持し続ける
    const history = this.state.isAscendingOrder ? this.state.history : this.state.history.slice().reverse();
    // 現在選択されているアイテムのインデックスを逆順の場合に反転させる
    const currentStepNumber = this.state.isAscendingOrder ? this.state.stepNumber : history.length - 1 - this.state.stepNumber;
    const current = history[currentStepNumber];
    const winInfo = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      // 逆順の場合はインデックスを反転させる
      const moveIndex = this.state.isAscendingOrder ? move : history.length - 1 - move;
      const desc = moveIndex ?
        'Go to move #' + moveIndex + '(' + step.location.col + ', ' + step.location.row + ')' :
        'Go to game start';
      return (
        <li key={moveIndex}>
          <button
            onClick={() => this.jumpTo(moveIndex)}
            className={move === currentStepNumber ? 'text-bold' : ''}
          >
            {desc}
          </button>
        </li>
      );
    });

    let status;
    if (winInfo.winner) {
      status = "Winner: " + winInfo.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)}
            causedWinCells={winInfo.causedWinCells}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
          <button onClick={() => this.reverseHistoryOrder()}>Reverse history order</button>
        </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 {
        winner: squares[a],
        causedWinCells: lines[i],
      };
    }
  }
  return {
    winner: null,
    causedWinCells: [],
  };
}

6. どちらも勝利しなかった場合、結果が引き分けになったというメッセージを表示する。

イメージはこんな感じ。
これ一番簡単なんじゃないか?
react-tutorial-improve-6.png

この部分に表示しているテキストはGameコンポーネントのrenderメソッド内にてstatusという変数で管理されています。

jsx
// 省略
class Game extends React.Component {
  // 省略
  render() {
    // 省略
    let status;
    if (winInfo.winner) {
      status = "Winner: " + winInfo.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)}
            causedWinCells={winInfo.causedWinCells}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
          <button onClick={() => this.reverseHistoryOrder()}>Reverse history order</button>
        </div>
      </div>
    );
  }
}
// 省略

ここに新しい条件を追加します。
引き分けというステータスは、「勝者がいないかつすべてのセルがnullではない("X"か"O"である)」です。

jsx
// 省略
class Game extends React.Component {
  // 省略
  render() {
    // 省略
    let status;
    if (winInfo.winner) {
      status = "Winner: " + winInfo.winner;
+   } else if (!current.squares.includes(null)) {
+     status = "Draw";
    } else {
      status = "Next player: " + (this.state.xIsNext ? "X" : "O");
    }
    // 省略
  }
}
// 省略

これで完成です。

【クリックで展開】ここまでのコードは以下になります。(CodePenはこちら
html(変更なし)
<div id="errors" style="
  background: #c00;
  color: #fff;
  display: none;
  margin: -20px -20px 20px;
  padding: 20px;
  white-space: pre-wrap;
"></div>
<div id="root"></div>
<script>
window.addEventListener('mousedown', function(e) {
  document.body.classList.add('mouse-navigation');
  document.body.classList.remove('kbd-navigation');
});
window.addEventListener('keydown', function(e) {
  if (e.keyCode === 9) {
    document.body.classList.add('kbd-navigation');
    document.body.classList.remove('mouse-navigation');
  }
});
window.addEventListener('click', function(e) {
  if (e.target.tagName === 'A' && e.target.getAttribute('href') === '#') {
    e.preventDefault();
  }
});
window.onerror = function(message, source, line, col, error) {
  var text = error ? error.stack || error : message + ' (at ' + source + ':' + line + ':' + col + ')';
  errors.textContent += text + '\n';
  errors.style.display = '';
};
console.error = (function(old) {
  return function error() {
    errors.textContent += Array.prototype.slice.call(arguments).join(' ') + '\n';
    errors.style.display = '';
    old.apply(this, arguments);
  }
})(console.error);
</script>
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;
}

.text-bold {
  font-weight: bold;
}

.square.caused-win {
  background: #fff799;
}
jsx
function Square(props) {
  return (
    <button
      className={'square' + (props.causedWin ? ' caused-win' : '')}
      onClick={props.onClick}
    >
      {props.value}
    </button>
  );
}

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

  render() {
    const rows = [0, 1, 2];
    const cols = [0, 1, 2];
    return (
      <div>
        {rows.map(row => {
          return (
            <div
              className="board-row"
              key={row}
            >
              {cols.map(col => {
                const cell = row * 3 + col;
                const causedWin = this.props.causedWinCells.includes(cell);
                return this.renderSquare(cell, causedWin);
              })}
            </div>
          );
        })}
      </div>
    );
  }
}

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [
        {
          squares: Array(9).fill(null),
          location: {
            col: null,
            row: null,
          },
        }
      ],
      stepNumber: 0,
      isAscendingOrder: true,
      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();
    const winInfo = calculateWinner(squares);
    if (winInfo.winner || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? "X" : "O";
    this.setState({
      history: history.concat([
        {
          squares: squares,
          location: {
            col: i % 3,
            row: Math.trunc(i / 3),
          },
        }
      ]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext
    });
  }

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

  reverseHistoryOrder() {
    this.setState({
      isAscendingOrder: !this.state.isAscendingOrder,
    });
  }

  render() {
    // 逆順の場合はthis.state.history配列のコピーを反転させる
    // これにより、this.state.historyはつねに昇順のデータを保持し続ける
    const history = this.state.isAscendingOrder ? this.state.history : this.state.history.slice().reverse();
    // 現在選択されているアイテムのインデックスを逆順の場合に反転させる
    const currentStepNumber = this.state.isAscendingOrder ? this.state.stepNumber : history.length - 1 - this.state.stepNumber;
    const current = history[currentStepNumber];
    const winInfo = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      // 逆順の場合はインデックスを反転させる
      const moveIndex = this.state.isAscendingOrder ? move : history.length - 1 - move;
      const desc = moveIndex ?
        'Go to move #' + moveIndex + '(' + step.location.col + ', ' + step.location.row + ')' :
        'Go to game start';
      return (
        <li key={moveIndex}>
          <button
            onClick={() => this.jumpTo(moveIndex)}
            className={move === currentStepNumber ? 'text-bold' : ''}
          >
            {desc}
          </button>
        </li>
      );
    });

    let status;
    if (winInfo.winner) {
      status = "Winner: " + winInfo.winner;
    } else if (!current.squares.includes(null)) {
      status = "Draw";
    } 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)}
            causedWinCells={winInfo.causedWinCells}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
          <button onClick={() => this.reverseHistoryOrder()}>Reverse history order</button>
        </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 {
        winner: squares[a],
        causedWinCells: lines[i],
      };
    }
  }
  return {
    winner: null,
    causedWinCells: [],
  };
}

後書き

できるだけチュートリアルの構文に則った書き方をしてみました。
最後の問題だけどうしても簡単すぎて、何か見落としているのではないかと思ってしまいます。
バグや、「ここはこうしたほうがいいんじゃない?」というようなポイントがあればコメントいただければ嬉しいです。

あと、この記事ではコードブロックのシンタックスハイライトをjsにしているのですが、QiitaではJSX構文で赤くエラーの表示がされてしまいます。
こちらについても解決策をご存じの方がいらっしゃいましたらコメントお待ちしています。

@d0ne1s さんからコメントで指摘をいただき、シンタックスハイライトをjsxに修正しました。

次はこれをTypeScriptで書いてみたい。

参考

React公式チュートリアル
React公式ガイド
React.js 実戦投入への道 - Qiita
React における State と Props の違い - Qiita
Reactのstate,props,componentの使い方を自分なりにまとめてみた - Qiita
React.jsでループするには。 - Qiita

5
3
2

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
5
3