Tutorial: Хрестики-нулики

Протягом цього туторіалу ви створите невелику гру в хрестики-нулики. Цей підручник не вимагає від вас жодних попередніх знань про React. Методи, які ви вивчите в цьому підручнику, є фундаментальними для створення будь-якого React-застосунку, і їх повне розуміння дасть вам глибоке розуміння React.

Цей підручник призначено для людей, які надають перевагу навчанню на практиці і хочуть швидко спробувати зробити щось відчутне. Якщо ви віддаєте перевагу вивченню кожної концепції крок за кроком, почніть з Опису інтерфейсу користувача.

Підручник поділено на декілька розділів:

Що ви будуєте?

У цьому туторіалі ви створите інтерактивну гру в хрестики-нулики за допомогою React.

Ви можете побачити, як це виглядатиме, коли ви закінчите, тут:

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

Якщо код ще не має сенсу для вас, або якщо ви не знайомі з синтаксисом коду, не хвилюйтеся! Мета цього підручника - допомогти вам зрозуміти React та його синтаксис.

Перед тим, як продовжити вивчення підручника, рекомендуємо вам ознайомитися з грою у хрестики-нулики, наведеною вище. Однією з особливостей, яку ви помітите, є те, що праворуч від ігрового поля є нумерований список. Цей список містить історію всіх ходів, які відбулися у грі, і він оновлюється по мірі проходження гри.

Після того, як ви погралися з готовою грою в хрестики-нулики, продовжуйте далі. У цьому підручнику ви почнете з простішого шаблону. Наш наступний крок - налаштувати вас так, щоб ви могли почати створювати гру.

Налаштування для підручника

У редакторі коду нижче натисніть Fork у верхньому правому куті, щоб відкрити редактор у новій вкладці за допомогою сайту CodeSandbox. CodeSandbox дозволяє писати код у браузері і переглядати, як користувачі побачать створений вами застосунок. На новій вкладці має з'явитися порожній квадрат і початковий код цього підручника.

export default function Square() {
  return <button className="square">X</button>;
}
* {
  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;
}

Ви також можете дотримуватися цього підручника, використовуючи ваше локальне середовище розробки. Для цього вам потрібно:

  1. Встановити Node.js
  2. На вкладці CodeSandbox, яку ви відкрили раніше, натисніть кнопку у верхньому лівому куті, щоб відкрити меню, а потім виберіть Файл > Експортувати до ZIP у цьому меню, щоб завантажити архів файлів локально
  3. Розархівуйте архів, потім відкрийте термінал і копіюйте до каталогу, який ви розархівували
  4. Встановіть залежності за допомогою npm install
  5. Запустіть npm start для запуску локального сервера і дотримуйтесь підказок для перегляду коду, що виконується у браузері

Якщо ви застрягли, не дозволяйте цьому зупинити вас! Натомість скористайтеся інструкцією онлайн, а пізніше спробуйте встановити локально.

Огляд

Тепер, коли ви налаштовані, давайте зробимо огляд React!

Перевірка початкового коду

У CodeSandbox ви побачите три основні розділи:

CodeSandbox with starter code
  1. Розділ файли зі списком файлів на кшталт App.js, index.js, styles.css та папкою з назвою public
  2. Редактор коду, де ви побачите вихідний код обраного файлу
  3. Частина браузера, де ви побачите, як буде відображатися написаний вами код

У розділі Файли слід вибрати файл App.js. Вміст цього файлу у редакторі коду має бути таким:

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

У секції browser має відображатися квадрат з хрестиком ось так:

x-filled square

Тепер погляньмо на файли у початковому коді.

App.js

Код у App.js створює компонент. У React компонент - це фрагмент коду, який можна використовувати повторно і який представляє частину користувацького інтерфейсу. Компоненти використовуються для рендерингу, керування та оновлення елементів інтерфейсу користувача у вашому застосунку. Давайте подивимось на компонент по рядках, щоб зрозуміти, що відбувається:

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

Перший рядок визначає функцію з назвою Square. Ключове слово JavaScript export робить цю функцію доступною за межами цього файлу. Ключове слово default повідомляє іншим файлам, які використовують ваш код, що це головна функція у вашому файлі.

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

Другий рядок повертає кнопку. Ключове слово JavaScript return означає, що все, що йде після нього, повертається як значення викликувачу функції. <button> є JSX елементом. JSX-елемент - це комбінація коду JavaScript і тегів HTML, яка описує те, що ви хочете відобразити. className="square" - це властивість кнопки або проп, який вказує CSS, як оформити кнопку. X - це текст, що відображається всередині кнопки, а </button> закриває елемент JSX, щоб вказати, що будь-який наступний вміст не повинен бути розміщений всередині кнопки.

styles.css

Клікніть по файлу з назвою styles.css у розділі Files CodeSandbox. Цей файл визначає стилі для вашого React-застосунку. Перші два селектори CSS (* і body) визначають стиль великих частин вашого застосунку, в той час як селектор .square визначає стиль будь-якого компонента, де властивість className має значення square. У вашому коді це буде кнопка з вашого компонента Square у файлі App.js.

index.js

Клацніть на файлі з назвою index.js у розділі Files CodeSandbox. Ви не будете редагувати цей файл під час уроку, але він є сполучною ланкою між компонентом, який ви створили у файлі App.js, та веб-браузером.

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';

import App from './App';

У рядках 1-5 зібрано всі необхідні частини:

  • React
  • Бібліотека React для спілкування з веб-браузерами (React DOM)
  • стилі для ваших компонентів
  • компонент, який ви створили у App.js.

Решта файлу збирає всі частини докупи і вставляє кінцевий продукт у index.html в публічній папці.

Створення дошки

Давайте повернемося до App.js. Тут ви проведете решту уроку.

Наразі на карті лише один квадрат, але вам потрібно дев'ять! Якщо ви просто спробуєте скопіювати і вставити ваш квадрат, то отримаєте два квадрати, як показано нижче:

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

Ви отримаєте таку помилку:

/src/App.js: Суміжні JSX-елементи повинні бути обгорнуті в охоплюючий тег. Ви хотіли отримати JSX-фрагмент <>...</>?

React-компоненти повинні повертати один JSX-елемент, а не декілька сусідніх JSX-елементів, як дві кнопки. Щоб виправити це, ви можете використовувати Фрагменти (<> та </>) для обгортання декількох сусідніх JSX-елементів, як ось:

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

Тепер ви повинні побачити:

two x-filled squares

Чудово! Тепер вам потрібно лише кілька разів скопіювати та вставити, щоб додати дев'ять квадратиків і..

nine x-filled squares in a line

О ні! Квадратики розташовані в одну лінію, а не у вигляді сітки, як потрібно для нашої дошки. Щоб виправити це, вам потрібно згрупувати квадратики у рядки за допомогою divs і додати кілька класів CSS. Також ви можете присвоїти кожному квадрату номер, щоб переконатися, що ви знаєте, де відображається кожен квадрат.

У файлі App.js оновіть компонент Square до такого вигляду:

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

CSS, визначений у styles.css стилізує divs з className з board-row. Тепер, коли ви згрупували свої компоненти у рядки зі стилізованим div, у вас є дошка для гри у хрестики-нулики:

tic-tac-toe board filled with numbers 1 through 9

Але тепер у вас виникла проблема. Ваш компонент з назвою Square насправді більше не є квадратом. Давайте виправимо це, змінивши назву на Board:

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

На цьому етапі ваш код має виглядати приблизно так:

export default function Board() {
  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>
    </>
  );
}
* {
  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;
}

Псссссс... Так багато треба набрати! Копіювати і вставляти код з цієї сторінки можна. Однак, якщо ви готові до невеликого виклику, ми рекомендуємо копіювати лише код, який ви хоча б раз набрали вручну самі.

Передача даних через пропси

.

Далі вам потрібно змінити значення квадратика з порожнього на "X", коли користувач натискає на нього. З урахуванням того, як ви побудували дошку, вам потрібно буде скопіювати і вставити код, який оновлює квадрат дев'ять разів (по одному разу для кожного квадрата, який у вас є)! Замість копіпасту архітектура компонентів React дозволяє вам створити компонент багаторазового використання, щоб уникнути невпорядкованого коду, що дублююється.

Перш за все, ви скопіюєте рядок, що визначає ваш перший квадрат (<button className="square">1</button>) з вашого компонента Board до нового компонента Square:

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

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

Тоді ви оновите компонент Board, щоб рендерити цей Square компонент за допомогою синтаксису JSX:

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

Зверніть увагу, що на відміну від браузерних divs, ваші власні компоненти Board та Square повинні починатися з великої літери.

Давайте подивимося:

one-filled board

О ні! Ви втратили пронумеровані квадратики, які були раніше. Тепер у кожній клітинці написано "1". Щоб виправити це, скористайтеся пропсами для передачі значення, яке повинен мати кожен квадрат, від батьківського компонента (Board) до його дочірнього (Square).

Оновлення компонента Square для читання пропу value, який буде передано з Board:

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

function Square({ value }) вказує на те, що компоненту Square можна передати проп з назвою value.

Тепер ви хочете вивести значення замість 1 всередині кожної клітинки. Спробуйте зробити це таким чином:

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

Упс, це не те, що ви хотіли:

value-filled board

Ви хотіли відобразити змінну JavaScript з назвою value з вашого компонента, а не слово "value". Щоб "втекти до JavaScript" з JSX, вам потрібні фігурні дужки. Додайте фігурні дужки навколо value у JSX так:

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

Наразі ви маєте бачити порожню дошку:

empty board

Це сталося тому, що компонент Board ще не передав проп value кожному компоненту Square, який він рендерить. Щоб виправити це, ви додасте проп value до кожного компонента Square, що рендериться компонентом Board:

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

Тепер ви знову повинні побачити сітку чисел:

tic-tac-toe board filled with numbers 1 through 9

Ваш оновлений код має виглядати так:

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>
    </>
  );
}
* {
  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;
}

Створення інтерактивного компонента

Давайте заповнимо компонент Square значенням X при натисканні на нього. Оголосіть функцію з назвою handleClick всередині Square. Потім додайте onClick до пропсів JSX-елемента кнопки, що повертається з Square:

function Square({ value }) {
  function handleClick() {
    console.log('clicked!');
  }

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

Якщо ви натиснете на квадрат зараз, ви побачите журнал з написом "clicked!" у вкладці Консоль внизу розділу Браузер у CodeSandbox. Якщо клацнути на квадратику більше одного разу, консоль "clicked!" буде записано ще раз. Повторні записи в консолі з тим самим повідомленням не призведуть до появи нових рядків у консолі. Замість цього ви побачите лічильник, що збільшується, поруч з першим "clicked!" журналом.

Якщо ви виконуєте настанови цього підручника за допомогою локального середовища розробки, вам потрібно відкрити консоль вашого браузера. Наприклад, якщо ви використовуєте браузер Chrome, ви можете переглянути консоль за допомогою комбінації клавіш Shift + Ctrl + J (на Windows/Linux) або Option + ⌘ + J (на macOS).

На наступному кроці ви хочете, щоб компонент Square "запам'ятав", що на ньому клацнули, і заповнив його позначкою "X". Для "запам'ятовування" компонентів використовується стан.

React надає спеціальну функцію useState, яку ви можете викликати з вашого компонента, щоб дозволити йому "запам'ятати" речі. Давайте збережемо поточне значення Square в стані, і змінимо його, коли натиснемо на Square.

Імпортуйте useState на початку файлу. Видаліть проп value з компонента Square. Замість цього додайте новий рядок на початку Square, який викликає useState. Нехай він повертає змінну стану з назвою value:

import { useState } from 'react';

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

  function handleClick() {
    //...

value зберігає значення, а setValue є функцією, яку можна використовувати для зміни значення. null, передане до useState, використовується як початкове значення для цієї змінної стану, тому value тут спочатку дорівнює null.

Оскільки компонент Square більше не приймає пропси, вам слід вилучити проп value з усіх дев'яти компонентів Square, створених компонентом Board:

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

Тепер ви зміните Square, щоб він показував "X" при натисканні. Замініть обробник події console.log("clicked!"); на setValue('X');. Тепер ваш компонент Square має такий вигляд:

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

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

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

Викликаючи цю функцію set з обробника onClick, ви вказуєте React на повторний рендеринг цього Square при кожному натисканні на його <button>. Після оновлення значення Square для буде 'X', тому ви побачите "X" на ігровому полі. Клацніть на будь-якій клітинці, і на ній з'явиться "X":

adding xes to board

Кожна клітинка має власний стан: значення, що зберігається у кожній клітинці, повністю не залежить від інших. Коли ви викликаєте функцію set у компоненті, React автоматично оновлює дочірні компоненти всередині.

Після внесення наведених вище змін ваш код матиме такий вигляд:

import { useState } from 'react';

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>
    </>
  );
}
* {
  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;
}

Інструменти для розробників React

React DevTools дозволяє перевіряти пропси та стан ваших React-компонентів. Ви можете знайти вкладку React DevTools внизу розділу browser в CodeSandbox:

React DevTools in CodeSandbox

Щоб побачити певний компонент на екрані, скористайтеся кнопкою у верхньому лівому куті React DevTools:

Selecting components on the page with React DevTools

Для локальної розробки React DevTools доступний як розширення для браузерів Chrome, Firefox та Edge. Встановіть їх, і у вашому браузері з'явиться вкладка Components Інструменти розробника для сайтів, що використовують React.

Завершення гри

На цьому етапі у вас є всі основні будівельні блоки для гри у хрестики-нулики. Для повноцінної гри вам потрібно по черзі розміщувати хрестики та нулики на дошці, і вам потрібен спосіб визначення переможця.

Підняття стану вгору

Наразі кожен Square компонент підтримує частину стану гри. Щоб визначити переможця у грі в хрестики-нулики, Board потрібно якось дізнатися стан кожного з 9 компонентів Square.

Як би ви до цього підійшли? Спочатку ви можете здогадатися, що Board має "запитати" кожен Square про стан цього Square. Хоча такий підхід технічно можливий у React, ми не рекомендуємо його використовувати, оскільки код стає важким для розуміння, схильним до помилок і важко піддається рефакторингу. Натомість, найкращим підходом є зберігання стану гри в батьківському компоненті Board, а не в кожному Square. Компонент Board може вказати кожному Square, що відображати, передавши проп, як ви зробили, коли передали число кожному квадрату.

Щоб зібрати дані з декількох дочірніх компонентів або щоб два дочірні компоненти взаємодіяли один з одним, оголосіть спільний стан у батьківському компоненті. Батьківський компонент може передати цей стан до дочірніх компонентів за допомогою пропсів. Таким чином дочірні компоненти будуть синхронізовані між собою та з батьківським компонентом.

Підняття стану в батьківський компонент є поширеним явищем при рефакторингу React-компонентів.

Скористаймося цією можливістю, щоб спробувати. Відредагуйте компонент Board так, щоб він оголошував змінну стану з назвою squares, яка за замовчуванням дорівнює масиву з 9 нулів, що відповідають 9 квадратам:

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

Array(9).fill(null) створює масив з дев'яти елементів і встановлює для кожного з них значення null. Виклик useState() навколо нього оголошує змінну стану squares, яка спочатку встановлюється на цей масив. Кожен запис у масиві відповідає значенню квадрата. Коли ви заповните дошку пізніше, масив squares матиме такий вигляд:

['O', null, 'X', 'X', 'X', 'O', 'O', null, null]

Тепер ваш Board компонент має передавати проп value до кожного Square, який він рендерить:

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, щоб отримати проп value з компонента Board. Для цього потрібно буде вилучити власне відстеження стану value компонента Square та проп onClick кнопки:

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

На цьому етапі ви маєте побачити порожнє поле для гри у хрестики-нулики:

empty board

А ваш код має виглядати так:

import { useState } from 'react';

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>
    </>
  );
}
* {
  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;
}

Кожна клітинка тепер отримає значення проп, яке буде або 'X', або 'O', або null для порожніх клітинок.

Далі вам потрібно змінити те, що відбувається при натисканні на Square. Компонент Board тепер підтримує заповнення клітинок. Вам потрібно створити спосіб для Square оновлювати стан Board. Оскільки стан є приватним для компонента, який його визначає, ви не можете оновити стан Board безпосередньо з Square.

Натомість, ви передасте функцію з компонента Board до компонента Square, і Square викличе цю функцію, коли буде натиснуто квадрат. Ви почнете з функції, яку викличе компонент Square при натисканні на нього. Ви викличете цю функцію onSquareClick:

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

Далі ви додасте функцію onSquareClick до пропсів компонента Square:

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

Тепер ви підключите проп onSquareClick до функції у компоненті Board, яку ви назвете handleClick. Щоб підключити onSquareClick до handleClick, вам слід передати функцію у проп onSquareClick першого компонента Square:

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

  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={handleClick} />
        //...
  );
}

Нарешті, ви визначите функцію handleClick всередині компонента Board для оновлення масиву squares, що містить стан вашої дошки:

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

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

  return (
    // ...
  )
}

Функція handleClick створює копію масиву squares (nextSquares) методом масиву JavaScript slice(). Потім handleClick оновлює масив nextSquares, щоб додати X до першого (індекс [0]) квадрата.

Виклик функції setSquares повідомляє React про зміну стану компонента. Це призведе до перерендерингу компонентів, які використовують стан squares (Board), а також його дочірніх компонентів (компонентів Square, які складають плату).

JavaScript підтримує закриття, що означає, що внутрішня функція (наприклад, handleClick) має доступ до змінних та функцій, визначених у зовнішній функції (наприклад, Board). Функція handleClick може читати стан squares і викликати метод setSquares, оскільки вони обидва визначені всередині функції Board.

Тепер ви можете додавати хрестики на дошку... але лише у верхній лівий квадрат. Ваша функція handleClick жорстко закодована на оновлення індексу для верхньої лівої клітинки (0). Давайте оновимо handleClick, щоб вона могла оновлювати будь-яку клітинку. Додамо до функції handleClick аргумент i, який отримує індекс клітинки для оновлення:

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

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

  return (
    // ...
  )
}

Далі вам потрібно буде передати ці i до handleClick. Ви можете спробувати встановити проп onSquareClick квадрата як handleClick(0) безпосередньо у JSX, але це не спрацює:

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

Ось чому це не працює. Виклик handleClick(0) буде частиною рендерингу компонента плати. Оскільки handleClick(0) змінює стан компонента плати за допомогою виклику setSquares, весь ваш компонент плати буде рендерити повторно. Але це призведе до повторного запуску handleClick(0), що призведе до нескінченного циклу:

Занадто багато повторних рендерингів. React обмежує кількість рендерів, щоб уникнути нескінченного циклу.

Чому ця проблема не виникла раніше?

Коли ви передавали onSquareClick={handleClick}, ви передали функцію handleClick як проп. Ви не викликали її! Але зараз ви викликаєте цю функцію одразу - зверніть увагу на дужки у handleClick(0) - і саме тому вона запускається занадто рано. Ви ж не хочете, щоб викликав handleClick до того, як користувач натисне!

Це можна виправити, створивши функцію на зразок handleFirstSquareClick, яка викликає handleClick(0), функцію на зразок handleSecondSquareClick, яка викликає handleClick(1), і так далі. Ви можете передати (а не викликати) ці функції як пропси на кшталт onSquareClick={handleFirstSquareClick}. Це вирішить проблему нескінченного циклу.

Втім, визначати дев'ять різних функцій і давати кожній з них назву - занадто багатослівно. Натомість, давайте зробимо так:

export default function Board() {
  // ...
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        // ...
  );
}

Зверніть увагу на новий синтаксис () =>. Тут () => handleClick(0) - це функція -стрілка, що є коротшим способом визначення функцій. При натисканні на квадрат виконується код після => "стрілки", який викликає handleClick(0).

Тепер вам потрібно оновити інші вісім квадратів, щоб викликати handleClick з переданих вами стрілочних функцій. Переконайтеся, що аргумент для кожного виклику handleClick відповідає індексу правильної клітинки:

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

Тепер ви знову можете додавати хрестики до будь-якої клітинки на дошці, клацнувши на ній:

filling the board with X

Але цього разу всім управлінням станом займається компонент Board!

Ось так має виглядати ваш код:

import { useState } from 'react';

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(i) {
    const nextSquares = squares.slice();
    nextSquares[i] = 'X';
    setSquares(nextSquares);
  }

  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>
    </>
  );
}
* {
  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;
}

Тепер, коли обробка вашого стану знаходиться у компоненті Board, батьківський компонент Board передає пропси дочірнім компонентам Square, щоб їх можна було правильно відобразити. При натисканні на Square дочірній компонент Square тепер запитує батьківський компонент Board про оновлення стану дошки. Коли стан Board змінюється, і компонент Board, і кожен дочірній компонент Square автоматично рендериться повторно. Збереження стану всіх клітинок у компоненті Board дозволить йому визначити переможця у майбутньому.

Давайте повторимо, що відбувається, коли користувач натискає на верхній лівий квадрат на вашій дошці, щоб додати на неї X:

  1. Натискання на верхній лівий квадрат запускає функцію, яку кнопка отримала як проп onClick з Square. Компонент Square отримав цю функцію як проп onSquareClick від Board. Компонент Board визначив цю функцію безпосередньо у JSX. Він викликає handleClick з аргументом 0.
  2. handleClick використовує аргумент (0) для оновлення першого елемента масиву squares з null до X.
  3. Стан квадратів компонента Board було оновлено, тому Board та всі його дочірні елементи відрендерено повторно. Це призводить до того, що проп value компонента Square з індексом 0 змінюється з null на X.

У результаті користувач бачить, що верхній лівий квадрат змінився з порожнього на X після натискання на нього.

Атрибут onClick елемента DOM <button> має особливе значення для React, оскільки це вбудований компонент. Для кастомних компонентів, таких як Square, іменування залежить від вас. Ви можете дати будь-яке ім'я пропсу onSquareClick у Square або функцію handleClick у Board, і код працюватиме однаково. У React прийнято використовувати імена onSomething для пропсів, які представляють події, та handleSomething для визначень функцій, які обробляють ці події.

Чому важлива незмінність

Зверніть увагу, що у handleClick ви викликаєте .slice() для створення копії масиву squares замість того, щоб змінити існуючий масив. Щоб пояснити чому, нам потрібно обговорити незмінюваність і чому незмінюваність важлива для вивчення.

Зазвичай існує два підходи до зміни даних. Перший підхід полягає у мутації даних шляхом безпосередньої зміни значень даних. Другий підхід полягає у заміні даних новою копією, яка містить бажані зміни. Ось як це виглядатиме, якщо ви зміните масив squares:

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

А ось як це виглядатиме, якщо змінити дані без мутації масиву squares:

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 обирає, коли повторно рендерити компонент, у memoпосібнику з API.

По черзі

Настав час виправити основний недолік цієї гри у хрестики-нулики: літери "О" неможливо позначити на дошці.

За замовчуванням першим ходом буде "X". Давайте відстежувати це, додавши ще один елемент стану до компонента Board:

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

  // ...
}

Щоразу, коли гравець робить хід, xIsNext (булеве значення) буде перевертатися, щоб визначити, який гравець ходить наступним, і стан гри буде збережено. Ви оновите функцію Board у handleClick, щоб перевернути значення xIsNext:

export default function Board() {
  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);
  }

  return (
    //...
  );
}

Тепер, коли ви натискатимете на різні квадратики, вони чергуватимуться між X та O, як і має бути!

Але зачекайте, є проблема. Спробуйте натиснути на ту саму клітинку декілька разів:

O overwriting an X

Документ X перезаписано на O! Хоча це додало б грі дуже цікавого повороту, ми поки що дотримуватимемося оригінальних правил.

Коли ви позначаєте квадрат за допомогою X або O, ви не перевіряєте, чи він вже має значення X або O. Це можна виправити за допомогою повернення раннього . Ви перевірите, чи клітинка вже має значення X або O. Якщо клітинку вже заповнено, ви повернете у функцію handleClick раніше, ніж вона спробує оновити стан дошки.

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

Тепер ви можете додавати X або O лише у порожні клітинки! Ось як має виглядати ваш код на цьому етапі:

import { useState } from 'react';

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]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }

  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>
    </>
  );
}
* {
  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;
}

Оголошення переможця

Тепер, коли гравці можуть ходити по черзі, ви захочете показати, коли гру виграно і більше немає ходів. Для цього ви додасте допоміжну функцію calculateWinner, яка отримує масив з 9 клітинок, перевіряє наявність переможця і повертає 'X', 'O' або null відповідно. Не хвилюйтеся про функцію calculateWinner; вона не є специфічною для React:

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

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

Не має значення, чи ви визначаєте calculateWinner до або після Board. Додамо його в кінець, щоб вам не доводилося прокручувати його щоразу, коли ви редагуєте компоненти.

Ви викликаєте calculateWinner(squares) у функції handleClick компонента Board, щоб перевірити, чи виграв гравець. Ви можете виконати цю перевірку одночасно з перевіркою того, чи натиснув користувач на клітинку, яка вже має X або O. В обох випадках ми хочемо повернутися назад:

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

Щоб повідомити гравцям про закінчення гри, ви можете вивести на екран такий текст, як "Переможець: X" або "Переможець: O". Для цього вам слід додати секцію status до компонента Board. У статусі буде показано переможця, якщо гру завершено, а якщо гра триває, то буде показано, хід якого гравця наступний:

export default function Board() {
  // ...
  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">
        // ...
  )
}

Вітаємо! Тепер у вас є робоча гра в хрестики-нулики. А ще ви щойно вивчили основи React. Тож ви справжній переможець. Ось як має виглядати код:

import { useState } from 'react';

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 (calculateWinner(squares) || squares[i]) {
      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;
}
* {
  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;
}

Додавання подорожі у часі

Наостанок давайте зробимо так, щоб можна було "повернутися у часі" до попередніх ходів у грі.

Зберігання історії ходів

Якщо ви мутували масив squares, реалізувати подорож у часі буде дуже складно.

Проте ви використовували slice() для створення нової копії масиву squares після кожного ходу і вважали його незмінним. Це дозволить вам зберігати кожну попередню версію масиву squares і переміщатися між ходами, які вже відбулися.

Минулі масиви squares буде збережено в іншому масиві з назвою history, який ви збережете як нову змінну стану. Масив history представляє всі стани дошки, від першого до останнього ходу, і має такий вигляд:

[
  // 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'],
  // ...
]

Знову підняти стан вгору

Тепер ви напишете новий компонент верхнього рівня з назвою Game для відображення списку попередніх ходів. У ньому ви розмістите стан history, який містить усю історію гри.

Помістивши стан history у компонент Game, ви зможете вилучити стан squares з його дочірнього компонента Board. Так само, як ви "підняли стан" з компонента Square у компонент Board, тепер ви піднімете його з Board у компонент верхнього рівня Game. Це надасть компоненту Game повний контроль над даними Board і дозволить йому доручити Board відрендерити попередні ходи з history.

Спочатку додайте компонент Game з експортом за замовчуванням. Нехай він відрендерить компонент 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>
  );
}

Зверніть увагу, що ви видаляєте ключові слова export default перед оголошенням function Board() { і додаєте їх перед оголошенням function Game() {. Це вказує вашому файлу index.js використовувати компонент Game як компонент верхнього рівня замість вашого компонента Board. Додаткові div, повернуті компонентом Game, звільняють місце для інформації про гру, яку ви додасте на дошку пізніше.

Додавання стану до компонента Game, щоб відстежувати, який гравець наступний, та історію ходів:

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  // ...

Зверніть увагу, що [Array(9).fill(null)] є масивом з одним елементом, який сам є масивом з 9 nulls.

Щоб відобразити клітинки для поточного ходу, вам потрібно прочитати останній масив клітинок з історії. Для цього вам не потрібен useState - ви вже маєте достатньо інформації, щоб обчислити його під час візуалізації:

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];
  // ...

Далі створіть функцію handlePlay всередині компонента Game, яку буде викликано компонентом Board для оновлення гри. Передайте xIsNext, currentSquares і handlePlay як пропси компоненту Board:

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) {
    // TODO
  }

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

Давайте зробимо компонент Board повністю контрольованим пропсами, які він отримує. Змініть компонент Board так, щоб він отримував три пропси: xIsNext, squares та нову функцію onPlay, яку Board може викликати з оновленим масивом squares, коли гравець робить хід. Далі видаліть перші два рядки функції Board, які викликають useState:

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    //...
  }
  // ...
}

Тепер замініть виклики setSquares та setXIsNext у handleClick у компоненті Board одним викликом вашої нової функції onPlay, щоб компонент Game міг оновлювати Board, коли користувач клацає на квадратик:

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

Компонент Board повністю контролюється пропсами, переданими йому компонентом Game. Вам потрібно реалізувати функцію handlePlay у компоненті Game, щоб відновити роботу гри.

Що має робити handlePlay при виклику? Пам'ятайте, що раніше Board викликав setSquares з оновленим масивом; тепер він передає оновлений масив squares до onPlay.

Функція handlePlay має оновити стан Game, щоб викликати повторний рендеринг але у вас більше немає функції setSquares, яку ви могли б викликати - тепер ви використовуєте змінну стану history для зберігання цієї інформації. Вам слід оновити history, додавши оновлений масив squares як новий запис історії. Ви також можете перемкнути xIsNext, як це було зроблено у Board:

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

Тут [...history, nextSquares] створює новий масив, який містить усі елементи з history, а потім nextSquares. (Синтаксис ...history spread можна прочитати як "перерахувати усі елементи у history".)

Наприклад, якщо history дорівнює [[null,null,null], ["X",null,null]], а nextSquares дорівнює ["X",null,"O"], то новий масив [...history, nextSquares] буде [[null,null,null], ["X",null,null], ["X",null,"O"]].

На цьому етапі ви перемістили стан у компонент Game, і інтерфейс повинен повністю працювати, як і до рефакторингу. Ось як має виглядати код на цьому етапі:

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

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{/*TODO*/}</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;
}
* {
  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;
}

Показати попередні ходи

Оскільки ви записуєте історію гри в хрестики-нулики, тепер ви можете показати гравцеві список попередніх ходів.

React-елементи на кшталт <button> є звичайними JavaScript-об'єктами; ви можете передавати їх у своєму застосунку. Щоб відрендерити декілька елементів у React, ви можете використовувати масив React-елементів.

У вас вже є масив history ходів у стані, тож тепер вам потрібно перетворити його на масив елементів React. У JavaScript для перетворення одного масиву в інший можна використати метод array map:

[1, 2, 3].map((x) => x * 2) // [2, 4, 6]

Ви будете використовувати map для перетворення вашої історії ходів у React-елементи, що представляють кнопки на екрані, та відображення списку кнопок для "переходу" до минулих ходів. Давайте накладемо map на history у компоненті Game:

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) {
    // TODO
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game 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>
  );
}

Нижче ви можете побачити, як має виглядати ваш код. Зауважте, що у консолі інструментів розробника ви маєте побачити помилку, яка виглядає наступним чино:

Warning: Кожен дочірній елемент масиву або ітератора повинен мати унікальний проп "ключ". Перевірте метод рендерингу `Game`.

Цю помилку буде виправлено у наступному розділі.

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 [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) {
    // TODO
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game 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>
  );
}

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

Під час ітерації масиву history всередині функції, яку ви передали до map, аргумент squares проходить через кожен елемент масиву history, а аргумент move проходить через кожен індекс масиву: 0, 1, 2, ..... (У більшості випадків вам знадобляться власне елементи масиву, але для виведення списку ходів вам знадобляться лише індекси)

Для кожного ходу в історії гри в хрестики-нулики ви створюєте елемент списку <li>, який містить кнопку <button>. Кнопка має обробник onClick, який викликає функцію jumpTo (яку ви ще не реалізували).

Наразі у консолі інструментів розробника ви маєте побачити список ходів, які відбулися у грі, та помилку. Давайте обговоримо, що означає помилка "ключа".

Вибір ключа

Коли ви рендерите список, React зберігає деяку інформацію про кожен елемент списку. Коли ви оновлюєте список, React має визначити, що саме змінилося. Можливо, ви додали, видалили, переставили або оновили елементи списку.

Уявіть собі перехід від

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

to

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

На застосунок до оновлених підрахунків, людина, яка читає це, ймовірно, скаже, що ви поміняли місцями замовлення Алекси та Бена і вставили Клаудію між Алексою та Беном. Однак React - це комп'ютерна програма і він не знає, що ви хотіли зробити, тому вам потрібно вказати властивість key для кожного елемента списку, щоб відрізнити кожен елемент списку від його братів і сестер. Якщо дані взято з бази даних, як ключі можна використати ідентифікатори бази даних Алекси, Бена та Клаудії.

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

Під час повторного рендерингу списку React бере ключ кожного елемента списку та шукає відповідний ключ у попередньому списку. Якщо поточний список має ключ, якого раніше не існувало, React створює компонент. Якщо в поточному списку відсутній ключ, який існував у попередньому списку, React знищує попередній компонент. Якщо два ключі збігаються, відповідний компонент переміщується.

Ключі повідомляють React про ідентичність кожного компонента, що дозволяє React підтримувати стан між повторними рендерингами. Якщо ключ компонента змінюється, компонент буде знищено і створено наново з новим станом.

key є спеціальною та зарезервованою властивістю в React. Коли створюється елемент, React витягує властивість key і зберігає ключ безпосередньо у повернутому елементі. Хоча key може виглядати так, ніби його передано як пропси, React автоматично використовує key, щоб вирішити, які компоненти оновлювати. Компонент не має можливості запитати, який key вказав його батько.

Наполегливо рекомендується призначати відповідні ключі при створенні динамічних списків. Якщо у вас немає відповідного ключа, ви можете розглянути можливість реструктуризації ваших даних таким чином.

Якщо ключ не вказано, React повідомить про помилку і використає індекс масиву як ключ за замовчуванням. Використання індексу масиву як ключа є проблематичним при спробі змінити порядок елементів списку або вставити/видалити елементи списку. Явна передача key={i} вимкне помилку, але має ті самі проблеми, що й індекси масиву, і не рекомендується у більшості випадків.

Ключі не обов'язково мають бути глобально унікальними; вони мають бути унікальними лише між компонентами та їхніми братами і сестрами.

Реалізація подорожей у часі

В історії гри хрестики-нулики кожен минулий хід має унікальний ідентифікатор, пов'язаний з ним: це порядковий номер ходу. Ходи ніколи не будуть перевпорядковані, видалені або вставлені посередині, тому безпечно використовувати індекс ходу як ключ.

У функції Game ви можете додати ключ як <li key={move}>, і якщо ви перезавантажите відрендерену гру, помилка React "key" повинна зникнути:

const moves = history.map((squares, move) => {
  //...
  return (
    <li key={move}>
      <button onClick={() => jumpTo(move)}>{description}</button>
    </li>
  );
});
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 [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) {
    // TODO
  }

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

Перш ніж реалізувати jumpTo, вам потрібно, щоб компонент Game відстежував, який крок зараз переглядає користувач. Для цього визначте нову змінну стану з назвою currentMove, за замовчуванням 0:

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[history.length - 1];
  //...
}

Далі, оновіть функцію jumpTo всередині Game, щоб оновити цю currentMove. Ви також встановите xIsNext як true, якщо число, на яке ви змінюєте currentMove є парним.

export default function Game() {
  // ...
  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
    setXIsNext(nextMove % 2 === 0);
  }
  //...
}

Тепер ви внесете дві зміни до функції Game у handlePlay, яка викликається при натисканні на квадратик.

  • Якщо ви "повертаєтеся у минуле", а потім робите новий хід з цієї точки, вам потрібно зберегти історію лише до цієї точки. Замість того, щоб додавати nextSquares після всіх елементів (синтаксис поширення ...) у історії, ви додасте його після всіх елементів у history.slice(0, currentMove + 1), щоб зберегти лише цю частину старої історії.
  • Під час кожного переміщення потрібно оновлювати currentMove, щоб вказувати на останній запис історії.
function handlePlay(nextSquares) {
  const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
  setHistory(nextHistory);
  setCurrentMove(nextHistory.length - 1);
  setXIsNext(!xIsNext);
}

Нарешті, ви зміните компонент Game, щоб рендерити поточний вибраний хід, замість того, щоб завжди рендерити фінальний хід:

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

  // ...
}

Якщо ви натиснете на будь-який крок в історії гри, дошка для хрестиків-нуликів має негайно оновитися, щоб показати, як виглядала дошка після цього ходу.

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 [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[currentMove];

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

  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
    setXIsNext(nextMove % 2 === 0);
  }

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

Фінальне очищення

Якщо ви уважно подивитеся на код, то помітите, що xIsNext === true коли currentMove парне і xIsNext === false коли currentMove непарне. Іншими словами, якщо ви знаєте значення currentMove, то ви завжди можете з'ясувати, яким має бути xIsNext.

Вам не потрібно зберігати обидва ці параметри у стані. Насправді, завжди намагайтеся уникати надлишкових станів. Спрощення того, що ви зберігаєте у стані, зменшує кількість помилок і робить ваш код простішим для розуміння. Змініть Game так, щоб він не зберігав xIsNext як окрему змінну стану, а визначав її на основі currentMove:

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

Вам більше не потрібне оголошення стану xIsNext або виклики setXIsNext. Тепер у xIsNext немає жодного шансу вийти з синхронізації з currentMove, навіть якщо ви помилитеся під час кодування компонентів.

Завершення

Вітаємо! Ви створили гру хрестики-нулики, яка:

  • Дозволяє грати у хрестики-нулики,
  • Вказує, коли гравець виграв гру,
  • Зберігає історію гри під час її проходження,
  • Дозволяє гравцям переглядати історію гри та бачити попередні версії ігрового поля.

Чудова робота! Сподіваємося, тепер ви відчуваєте, що добре розумієте, як працює React.

Перевірте кінцевий результат тут:

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

Якщо у вас є додатковий час або ви хочете попрактикувати свої нові навички роботи з React, ось кілька ідей щодо покращення гри в хрестики-нулики, перелічених у порядку зростання складності:

  1. Тільки для поточного ходу показувати "Ви перебуваєте на кроці #..." замість кнопки.
  2. Перепишіть Board, щоб використовувати два цикли для створення квадратів замість їх жорсткого кодування.
  3. Додано кнопку перемикача, яка дозволяє сортувати ходи за зростанням або спаданням.
  4. Коли хтось виграє, підсвічувати три клітинки, які спричинили виграш (а коли ніхто не виграє, виводити повідомлення про нічию).
  5. Відображення розташування для кожного переміщення у форматі (рядок, стовпчик) у списку історії переміщень.

Протягом цього підручника ви торкалися концепцій React, таких як елементи, компоненти, пропси та стан. Тепер, коли ви побачили, як ці концепції працюють при створенні гри, перегляньте Мислення в React, щоб побачити, як ті ж концепції React працюють при створенні інтерфейсу програми.