2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React公式ドキュメントの理解(チュートリアル:三目並べ)

Posted at

Reactを独学で学ぶ上で、いちばん最初に公式ドキュメントの理解から着手しようと思ったのでアウトプットも兼ねて内容をまとめました。
ここではReact公式ドキュメントの「チュートリアル:三目並べ」をまとめました。

こんな人向け

  • Reactを触ったことがない人
  • Reactに興味があり、学ぼうとしている
  • Reactの概念・仕組みを知りたい

筆者の状態

  • html, css, javaScriptはある程度知っている
  • Reactは初心者

チュートリアル:三目並べ

チュートリアルで作成するもの

以下、チュートリアルで作成する完成物の三目並べゲームになります。

Apo.tsx
import { useState } from 'react';

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

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

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

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const xIsNext = currentMove % 2 === 0;
  const currentSquares = history[currentMove];

  function handlePlay(nextSquares) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1);
  }

  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

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;
}

チュートリアルを作成する前に、まずは上記のコードを動作させて動きを確認してみるのもよいと思います。

チュートリアルのセットアップ

Reactの公式サイトでは、CodeSandbox を使用してブラウザ上でコードを書きながら画面表示の確認もできるようになっているので、そちらの方が楽だと思う方はReact公式サイトからチュートリアル作成を行ってください。
ここではローカルにReactプロジェクトを作成して、チュートリアルを実施していきたいと思います。

■Reactプロジェクトの作成
プロジェクトを作成したいローカルディレクトリに移動して

npm create vite@latest
...
Project name:
tutorial

Select a framework:
React

Select a variant:
TypeScript

tutorialフォルダに移動して、npm i

cd tutorial

npm i

これでローカルにReactプロジェクトを作成することが出来ました。

index.cssを下記のようにしましょう。

index.css
* {
  box-sizing: border-box;
}

body {
  font-family: sans-serif;
  margin: 20px;
  padding: 0;
}

h1 {
  margin-top: 0;
  font-size: 22px;
}

h2 {
  margin-top: 0;
  font-size: 20px;
}

h3 {
  margin-top: 0;
  font-size: 18px;
}

h4 {
  margin-top: 0;
  font-size: 16px;
}

h5 {
  margin-top: 0;
  font-size: 14px;
}

h6 {
  margin-top: 0;
  font-size: 12px;
}

code {
  font-size: 1.2em;
}

ul {
  padding-inline-start: 20px;
}

* {
  box-sizing: border-box;
}

body {
  font-family: sans-serif;
  margin: 20px;
  padding: 0;
}

.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;
}

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

.status {
  margin-bottom: 10px;
}
.game {
  display: flex;
  flex-direction: row;
}

.game-info {
  margin-left: 20px;
}

App.tsxを下記のようにしましょう。

App.tsx
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
import "./App.css";

export default function Square() {
  return <button className="square">X</button>;
}

App.tsxにあるコードはコンポーネントを作成します。ReactのコンポーネントとはUIの部品を表す再利用可能なコードのことです。コンポーネントは、アプリケーションのUI要素を表示し、管理し、更新するために使用します。
App.tsxを見ると

App.tsx
export default function Square() {  //ここ
  return <button className="square">X</button>;
}

export というキーワードは、この関数をこのファイルの外部からアクセスできるようにします。default キーワードは、このコードを使用する他のファイルに、これがこのファイルのメイン関数であるということを伝えます。

App.tsx
export default function Square() {
  return <button className="square">X</button>; //ここ
}

<button> はJSX要素(JSX element)と呼ばれます。JSX要素とは、何を表示したいかを記述するためのJavaScriptコードとHTMLタグの組み合わせです。classNmae="square" はこのボタンのプロパティ、またはpropsと呼ばれるもので、CSSにボタンをどのようにスタイル付けするか伝えます。

main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'

import App from './App.tsx'

1~5行目の説明をします。

  • React
  • Webブラウザとやり取りするためのReactライブラリ(React DOM)
  • コンポーネント用のスタイル
  • App.tsx で作成したコンポーネント

盤面の作成

App.tsxに戻ります。
現在の盤面(board)にはマス目(square)が1つしかありませんが、本来は9つ必要です。
ですがReactコンポーネントからは、JSX要素を複数隣り合わせて返すのではなく、単一のJSX要素を返す必要があります。
なので複数のJSX要素は以下のように(<> および </>)で囲むようにします。

export default function Square() {
 return (
  <>
   <button className="square">X</button>
   <button className="square">X</button>
  </>
 );
}

こんな感じで表示されるはずです。
image.png

これをグリッド状に並べたいので、<div>を使用してグループ分けを行います。

export default function Square() {
  return (
    <>
      <div className="board-row">
        <button className="square">1</button>
        <button className="square">2</button>
        <button className="square">3</button>
      </div>
      <div className="board-row">
        <button className="square">4</button>
        <button className="square">5</button>
        <button className="square">6</button>
      </div>
      <div className="board-row">
        <button className="square">7</button>
        <button className="square">8</button>
        <button className="square">9</button>
      </div>
    </>
  );
}

Web画面にはこのように表示されているはずです。
image.png

ここでSquare 関数の名前をBoard に変えましょう。

propsを通してデータを渡す

button タグのコピーではごちゃごちゃしているのでコンポーネントの再利用で実装してみましょう。
新しく Square 関数を作成します。

function Square() {
  return <button className="square">1</button>;
}

export default function Board() {
  // ...
}

次に、Board コンポーネントを更新し、JSX構文を利用してこの Square コンポーネントをレンダーしましょう。

App.tsx
// ...
export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
    </>
  );
}

コンポーネントは大文字で始める必要があることに注意してください。

propsとして Boardコンポーネントから Square コンポーネントに渡して
1-9までのマス目を作成していきます。

App.tsx
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
import "./App.css";

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

export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square value="1" />
        <Square value="2" />
        <Square value="3" />
      </div>
      <div className="board-row">
        <Square value="4" />
        <Square value="5" />
        <Square value="6" />
      </div>
      <div className="board-row">
        <Square value="7" />
        <Square value="8" />
        <Square value="9" />
      </div>
    </>
  );
}

インタラクティブなコンポーネントの作成

では Square コンポーネントをクリックすると X が表示されるようにしてみましょう。
まず、Square コンポーネントに、クリックされたことを「記憶」して"X"マークを表示してもらいます。何かを「記録」するために、コンポーネントはstateというのものを使用します。
Reactの useState を使用して Square の現在の値をstateに保存し、Square がクリックされたときにその値を変更しましょう。

App.tsx
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
import "./App.css";

function Square() {
  const [value, setValue] = useState(null);

  function handleClick() {
    setValue("X");
  }

  return (
    <button className="square" onClick={handleClick}>
      {value}
    </button>
  );
}

export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
    </>
  );
}

このように表示されるはずです。
image.png

各Squareはそれぞれ独自のstateを保持しています。それぞれのSquareに格納されている value は、他のモノとは完全に独立しています。コンポーネントの set 関数を呼び出すと、Reactは自動的に内部にある子コンポーネントも更新します。

ゲームを完成させる

ここまでの作業で、三目並べゲームの基本的な土台は完成しました。
ゲーム自体を完成さえるために"X"と"〇"を置けるようにすることと、勝ち負けのロジックを追加します。

Stateのリフトアップ

Board コンポーネントは各 Square のどの位置にマークが置かれているか知る必要があります。ですので親コンポーネント Board に共有のstateを宣言し、子コンポーネント Square にprops経由で渡します。

App.tsx
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
import "./App.css";

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

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} />
        <Square value={squares[1]} />
        <Square value={squares[2]} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} />
        <Square value={squares[4]} />
        <Square value={squares[5]} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} />
        <Square value={squares[7]} />
        <Square value={squares[8]} />
      </div>
    </>
  );
}

すでに Square から Board のstateを変更することはできないので、Board コンポーネントから Square コンポーネントに関数を渡して、マス目がクリックされたときに Square にその関数を呼び出してもらうようにします。

App.tsx
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
import "./App.css";

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

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick() {
    const nextSquares = squares.slice();
    nextSquares[0] = "X";
    setSquares(nextSquares);
  }
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={handleClick} />
        <Square value={squares[1]} onSquareClick={handleClick} />
        <Square value={squares[2]} onSquareClick={handleClick} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={handleClick}/>
        <Square value={squares[4]} onSquareClick={handleClick}/>
        <Square value={squares[5]} onSquareClick={handleClick}/>
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={handleClick}/>
        <Square value={squares[7]} onSquareClick={handleClick}/>
        <Square value={squares[8]} onSquareClick={handleClick}/>
      </div>
    </>
  );
}

handleClick 関数は、slice() 配列メソッドを使って squares 配列のコピー( nextSquares)を作成します。※slice は引数が0だとコピーを作成する

次に、handleClick は、nextSquares 配列を更新して最初の(インデックス [0] の)マス目に X と書き込みます。
seSquares 関数をコールすることで、Reactはこのコンポーネントのstateに変更があったことを知ります。これにより、squares というstate変数を使用しているコンポーネント Board 、およびその子コンポーネント(盤面を構成している Square コンポーネントすべて)の再レンダーがトリガされます。

任意のマス目を変更できるようにすると

function handleClick(i) {
    const nextSquares = squares.slice();
    nextSquares[i] = "X";
    setSquares(nextSquares);
}

次に、その ihandleClick に渡す必要があります。以下のように、JSX内で直接渡したくなりますが、これはうまくいきません。

<Square value={squares[0]} onSquareClick={handleClick(0)} />

これがうまくいかない理由は、handleClick(0) の呼び出しが、Boardコンポーネントのレンダーの一部として発生してしまうからです。handleClick(0) は、setSquares を呼び出してBoardコンポーネントのstateを更新するため、Boardコンポーネント全体が再レンダーされます。これにより、handleClick(0) が再度実行され、無限ループに陥ります。
しかし、propsとして(onSquareClick={handleClick})のように渡すと関数は実行されず呼び出されないので、無限ループにはなりません。
じゃあどうすればよいかというと、アロー関数を使います。

export default function Board() {
  // ...
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        // ...
  );
}
App.tsx
export default function Board() {
  // ...
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
};

クリックされた場所に"X"を置けるようになりました。
image.png

state 管理が Board コンポーネントに移動されたので、親である Board コンポーネントは子の Square コンポーネントに props を渡すことで、それらが正しく表示されるようにしています。Square をクリックすると、子である Square コンポーネントが親である Board コンポーネントに盤面の state を更新するように要求します。Board の state が変更されると、Board コンポーネントとすべての Square 子コンポーネントが自動的に再レンダーされます。すべてのマス目の state を Board コンポーネントにまとめて保持しておくことで、この後でゲームの勝者を決めることが可能になります。

シーケンスを再確認

ユーザが盤面の左上のマス目をクリックして X を置いた場合を例に、何が起こるのかをおさらいしましょう。

左上のマス目をクリックすると、button が props として受け取った onClick 関数が実行されます。Square コンポーネントはその関数を Board から onSquareClick プロパティとして受け取っています。Board コンポーネントはその関数を JSX の中で直接定義しています。その関数は引数 0 で handleClick を呼び出します。
handleClick は引数 0 を使って、squares 配列の最初の要素を null から X に更新します。
Board コンポーネントの state である squares が更新されたので、Board とそのすべての子が再レンダーされます。これにより、インデックス 0 である Square コンポーネントの value プロパティが null から X に変更されます。
最終的に、クリックによって左上のマス目が空白から X に変わったという結果をユーザが目にすることになります。

なぜイミュータビリティが重要なのか

handleClick 内で既存の squares 配列を直接変更するのではなく、.slice() を使ったのでしょうか?それはイミュータビリティ(不変性)という概念と
それが重要であるからです。
データを変更する方法は一般的に2つのアプローチがあります。
1つめのアプローチはデータの値を直接書き換える方法です。

const squares = [null, null, null, null, null, null, null, null, null];
squares[0] = 'X';
// Now `squares` is ["X", null, null, null, null, null, null, null, null];

一方で、以下が元データを書き換えない方法です。

const squares = [null, null, null, null, null, null, null, null, null];
const nextSquares = ['X', null, null, null, null, null, null, null, null];
// Now `squares` is unchanged, but `nextSquares` first element is 'X' rather than `null`

この利点は以下です。

  • Reactがコンポーネントの変化を検知して再レンダーするため、無駄なレンダーを防ぎ、パフォーマンスの効率を上げるため
  • 元のデータを再利用したい場合があるため

次に、"X"と"〇"を交互に置けるようにします。

  const [xIsNext, setXIsNext] = useState(true);
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(i) {
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = "X";
    } else {
      nextSquares[i] = "O";
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }

このようになります。
image.png
今のままでは"X"が"〇"に上書きされてしまうので

function handleClick(i) {
  if (squares[i]) {
    return;
  }
  const nextSquares = squares.slice();
  //...
}

すでにマークがあれば、戻るようにします。

勝者の宣言

勝者がどちらかのロジックを追加します。
Board は勝者が決まればゲームを終了し、勝者を表示します。ゲーム中は次のプレイヤーを表示します。

App.tsx
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
import "./App.css";

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

export default function Board() {
  const [xIsNext, setXIsNext] = useState(true);
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(i) {
    if (squares[i] || calculateWinner(squares)) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = "X";
    } else {
      nextSquares[i] = "O";
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }
  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = "Winner: " + winner;
  } else {
    status = "Next player: " + (xIsNext ? "X" : "O");
  }
  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

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;
}

次のプレイヤー
image.png
勝ったとき
image.png

タイムトラベルの追加

巻き戻し要素を入れていきます。

着手の履歴を保持する

過去の squares 配列を、histroy という名前の別配列に入れて、それを新たにstate変数として保持することにします。histroy 配列は、最初の着手から最新の着手まで、盤面のすべての状態を表現しており、以下のような形になります。

[
  // Before first move
  [null, null, null, null, null, null, null, null, null],
  // After first move
  [null, null, null, null, 'X', null, null, null, null],
  // After second move
  [null, null, null, null, 'X', null, null, null, 'O'],
  // ...
]

もう一度stateをリフトアップ

新しいトップレベルのコンポーネント、Game を作成して、過去の着手の一覧を表示するようにします。ゲームの履歴全体を保持するstateである history はここに置きます。

history 状態を Game コンポーネントに配置することで、その子になる Board コンポーネントからは squares の state を削除できます。Square コンポーネントから Board コンポーネントに「state をリフトアップ」したときと同じように、Board からトップレベルの Game コンポーネントに state をリフトアップすることになります。これにより、Game コンポーネントは Board のデータを完全に制御し、history からの過去の盤面の状態を Board にレンダーさせることができます。

function Board() {
  // ...
}

export default function Game() {
  return (
    <div className="game">
      <div className="game-board">
        <Board />
      </div>
      <div className="game-info">
        <ol>{/*TODO*/}</ol>
      </div>
    </div>
  );
}

過去の着手の表示

複数の履歴を表示するためにmap関数を使います。

App.tsx
function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = "X";
    } else {
      nextSquares[i] = "O";
    }
    onPlay(nextSquares);
  }
//...
}
export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquares) {
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }

  function jumpTo(nextMove) {}

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = "Go to move #" + move;
    } else {
      description = "Go to move start";
    }
    return (
      <li>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

ただ、今の状態では以下のエラーが出ます。

hook.js:608  Each child in a list should have a unique "key" prop.

keyを選ぶ

リストをレンダーすると、React はレンダーされたリストの各アイテムに関するとある情報を保持します。そのリストが更新されると、React は何が変更されたのかを判断する必要があります。例えばリストのアイテムを追加したのかもしれませんし、削除、並べ替え、あるいは項目の中身の更新を行ったのかもしれません。

次のような状況から:

<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>

以下に遷移したと想像してください:

<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>

人間がこれを読めばおそらく、カウントの数字が変わっていることに加えて、Alexa と Ben の順番が入れ替わり、Claudia が Alexa と Ben の間に挿入された、と言うことでしょう。しかし、Reactはコンピュータプログラムでありあなたの意図を理解できません。

なのでReactでは以下のように記述してあげる必要があります。

<li key={user.id}>
  {user.name}: {user.taskCount} tasks left
</li>

Reactにおけるリストとkeyプロパティ

Reactでリストをレンダリングする際、各リスト項目にkeyという特別なプロパティを指定することが重要です。

  • keyの役割: Reactはkeyを使って、リスト内の各項目を識別します。リストが再レンダリングされる際、Reactはこのkeyを頼りに、以前のリストと比較して、どの項目が追加・削除・移動されたかを効率的に判断します。
  • Stateの維持: keyが同じであれば、対応するコンポーネントは維持され、その内部状態(state)も保持されます。逆にkeyが変わると、古いコンポーネントは破棄され、新しいstateを持つ新しいコンポーネントが作成されます。
  • keyはReact内部用: keyはpropsのようには見えますが、子コンポーネントに直接渡されるわけではなく、Reactが更新を判断するために内部的に使用します。
  • 適切なkeyの設定: 動的なリストでは、各項目を一意に識別できる安定したkey(例:データのID)を設定することが強く推奨されます。適切なIDがない場合は、データ構造を見直すことも検討しましょう。
  • インデックスをkeyにするのは非推奨: keyを指定しない場合、Reactはエラーを出し、デフォルトで配列のインデックスを使用します。しかし、インデックスをkeyとして使うと、リストの並び替えや項目の挿入・削除時に予期せぬ挙動やパフォーマンスの問題を引き起こす可能性があるため、ほとんどの場合推奨されません。
  • keyの一意性の範囲: keyはリスト全体で完全に一意である必要はなく、同じリスト内の兄弟要素間で一意であれば十分です。

タイムトラベルの実装

戻りたい時点のボタンを押下すると、履歴がその時点まで戻るようにします。

App.tsx
import { useState } from "react";

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

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = "X";
    } else {
      nextSquares[i] = "O";
    }
    onPlay(nextSquares);
  }

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

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const xIsNext = currentMove % 2 === 0;
  const currentSquares = history[currentMove];

  function handlePlay(nextSquares) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1);
  }

  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
  }
  
  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = "Go to move #" + move;
    } else {
      description = "Go to move start";
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

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;
}

お疲れ様でした。
こちらでチュートリアルは終了です。
Reactの公式を全て載せているわけではないので、あくまで自身のアウトプット用として書かせていただきました。

本記事は、Reactの公式ドキュメント(React 公式サイトへのリンク)を参考に、Creative Commons Attribution 4.0 International License (CC BY 4.0) の下で公開されている情報に基づいて作成されています。

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?