본문 바로가기
Dev/React

[공식문서] React - 튜토리얼: Tic-Tac-Toe

by ZEROGOON 2023. 9. 16.

이 튜토리얼에서는 React를 사용하여 작은 틱택토 게임을 만들 것입니다. 이 튜토리얼은 기존의 React 지식을 가정하지 않습니다. 튜토리얼에서 배울 기술은 모든 React 앱을 구축하는 데 필수적이며 완전히 이해하면 React에 대한 심도 있는 이해를 얻을 수 있습니다.

참고
이 튜토리얼은 직접 해보며 학습하고 무언가 구체적인 것을 빠르게 만들어 보기를 선호하는 사람들을 위해 설계되었습니다. 각 개념을 단계별로 배우는 것을 선호하는 경우 "UI 설명"부터 시작하십시오.

 

이 튜토리얼은 다음 섹션으로 나뉩니다:

 

  • 튜토리얼을 위한 설정: 튜토리얼을 따라갈 수 있는 시작점을 제공합니다.
  • 개요: React의 기초인 컴포넌트, 프롭스(props), 상태(state)를 가르칩니다.
  • 게임 완성: 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;
}

App.js 실행화면

 

코드가 아직 이해되지 않거나 코드 구문에 익숙하지 않다면 걱정하지 마세요! 이 튜토리얼의 목표는 React와 그 구문을 이해하는 데 도움을 주는 것입니다.

 

튜토리얼을 계속하기 전에 위의 틱택토 게임을 확인하는 것을 권장합니다. 눈에 띄는 기능 중 하나는 게임 보드 오른쪽에 번호가 매겨진 목록이 있는 것입니다. 이 목록은 게임에서 발생한 모든 움직임의 기록을 제공하며 게임이 진행됨에 따라 업데이트됩니다.

 

완성된 틱택토 게임을 테스트하고 나면 계속해서 진행하세요. 이 튜토리얼에서는 더 간단한 템플릿으로 시작합니다. 다음 단계는 게임을 만들 준비를 하기 위한 것입니다.

 

튜토리얼 설정

아래의 라이브 코드 편집기에서, 오른쪽 상단의 "Fork"를 클릭하여 웹 사이트 CodeSandbox를 사용하여 새 탭에서 편집기를 엽니다. CodeSandbox를 사용하면 브라우저에서 코드를 작성하고 사용자가 만든 앱을 어떻게 보게 될지 미리 볼 수 있습니다. 새 탭에는 빈 사각형과 이 튜토리얼의 시작 코드가 표시됩니다.

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

 

참고
로컬 개발 환경을 사용하여이 튜토리얼을 따를 수도 있습니다. 이를 위해서는 다음을 수행해야합니다:

1. Node.js 설치
2. Node.js 설치이전에 열었던 CodeSandbox 탭에서 상단 좌측 모서리 버튼을 눌러 메뉴를 열고 해당 메뉴에서 파일 > ZIP로 내 보내기를 선택하여 파일을 로컬로 다운로드합니다.
3. 압축 해제하고 터미널을 열어 압축 해제 한 디렉토리로 이동합니다.
4. npm install 명령을 사용하여 종속성을 설치합니다.
5. npm start를 실행하여 로컬 서버를 시작하고 브라우저에서 실행중인 코드를 확인하기 위한 프롬프트를 따릅니다.

문제가 발생하면 포기하지 마세요! 온라인으로 따라하기를 계속하고 나중에 로컬 설정을 다시 시도해 보세요.

 

개요

설정이 완료되었으므로 React의 개요를 알아보겠습니다!

 

시작 코드 검사

CodeSandbox에서 다음과 같은 세 가지 주요 섹션을 볼 수 있습니다:

CodeSandbox 3가지 섹션

  1. 파일 섹션: App.js, index.js, styles.css 및 public이라는 폴더와 같은 파일 목록이 포함된 섹션입니다.
  2. 코드 편집기: 선택한 파일의 소스 코드가 표시되는 위치입니다.
  3. 브라우저 섹션: 작성한 코드가 어떻게 표시될지를 볼 수 있는 위치입니다.

 

App.js 파일이 파일 섹션에서 선택되어야 합니다. 코드 편집기의 해당 파일 내용은 다음과 같아야 합니다.

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

 

브라우저 섹션에는 "x"가 채워진 사각형이 표시되어야 합니다. 이것은 다음과 같아야 합니다.

이제 시작 코드의 파일을 살펴 보겠습니다.

 

App.js:

App.js 파일의 코드는 컴포넌트를 생성합니다. React에서 컴포넌트는 사용자 인터페이스의 일부를 나타내는 재사용 가능한 코드 조각입니다. 컴포넌트는 응용 프로그램의 UI 요소를 렌더링, 관리 및 업데이트하는 데 사용됩니다. 코드를 행별로 살펴보겠습니다.

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

첫 번째 줄은 Square라는 함수를 정의합니다. export 키워드는 이 함수를 이 파일 외부에서도 접근 가능하게 만듭니다. default 키워드는 다른 파일들에게 이 파일의 주요 함수임을 알려줍니다.

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

두 번째 줄은 버튼을 반환합니다. return 키워드는 그 뒤에 오는 내용을 함수 호출자에게 반환하는 것을 의미합니다. <button>은 JSX 요소입니다. JSX 요소는 JavaScript 코드와 HTML 태그의 조합으로, 표시하려는 내용을 설명합니다. className="square"은 버튼을 어떻게 스타일링할지를 CSS에 알려주는 버튼 속성 또는 프롭(prop)입니다. X는 버튼 안에 표시되는 텍스트이며, </button>는 JSX 요소를 닫아서 이후의 내용이 버튼 내부에 배치되지 않도록 합니다.

 

styles.css

CodeSandbox의 파일 섹션에서 styles.css로 표시된 파일을 클릭하십시오. 이 파일은 React 앱의 스타일을 정의합니다. 처음 두 개의 CSS 선택기 (* 및 body)는 앱의 큰 부분의 스타일을 정의하고 .square 선택기는 className 속성이 square로 설정된 모든 컴포넌트의 스타일을 정의합니다. 코드에서는 App.js 파일의 Square 컴포넌트에 해당하는 것입니다.

 

index.js

CodeSandbox의 파일 섹션에서 index.js로 표시된 파일을 클릭하십시오. 이 파일은 튜토리얼 동안 편집하지는 않지만 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에서 생성한 컴포넌트

파일의 나머지 부분은 모든 구성 요소를 모아서 최종 결과물을 public 폴더의 index.html로 삽입합니다.

 

게임 보드 만들기

App.js로 돌아가 봅시다. 여기서 튜토리얼 나머지 부분을 진행할 것입니다.

 

현재 게임 보드는 단 하나의 정사각형만 있지만 9개가 필요합니다! 단순히 현재의 정사각형을 복사하여 두 개를 만들려고 하면 다음과 같이 하면 안 됩니다:

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

이 에러를 보게 됩니다:

/src/App.js: Adjacent JSX elements must be wrapped in an enclosing tag. 
Did you want a JSX fragment <>...</>?

React 컴포넌트는 하나의 JSX 요소를 반환해야하며 두 개의 버튼과 같이 여러 인접한 JSX 요소를 반환해서는 안됩니다. 이 문제를 해결하기 위해 다음과 같이 <>와 </>를 사용하여 여러 개의 인접한 JSX 요소를 래핑할 수 있습니다:

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

이제 다음과 같이 보일 것입니다:

좋습니다! 이제 몇 번 복사하여 아홉 개의 사각형을 추가하고...

오 안돼요! 사각형은 모두 한 줄에 있으며, 필요한 보드 형태인 그리드에 있지 않습니다. 이 문제를 해결하려면 사각형을 div로 그룹화하고 일부 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>
    </>
  );
}

styles.css에서 정의한 CSS는 className이 board-row인 div를 스타일링합니다. 이제 컴포넌트를 스타일링된 div로 그룹화하여 틱택토 보드를 얻었습니다.

그러나 이제 문제가 생겼습니다. 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>
    </>
  );
}
참고: 많이 입력하기 귀찮다면 이 페이지에서 코드를 복사하여 붙여넣어도 괜찮습니다. 그러나 조금 도전하고 싶다면 최소한 한 번이라도 직접 입력한 코드만 복사하는 것을 권장합니다.

 

Props를 통한 데이터 전달

다음으로 사용자가 해당 사각형을 클릭할 때 사각형의 값을 "X"로 변경하려고 합니다. 지금까지 보드를 만드는 방법으로는 사각형을 업데이트하는 코드를 사각형마다 9번 복사해야 할 것입니다. 대신 복사 붙여넣기 대신 React의 컴포넌트 아키텍처를 사용하여 지저분하고 중복된 코드를 피할 수 있습니다.

 

먼저 첫 번째 사각형 (<button className="square">1</button>)을 Board 컴포넌트에서 새로운 Square 컴포넌트로 복사하겠습니다.

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

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

그런 다음 JSX 구문을 사용하여 Board 컴포넌트에서 해당 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>
    </>
  );
}

브라우저 div와 달리 자체 컴포넌트 Board와 Square는 대문자로 시작해야 함에 유의하세요.

 

아래를 살펴보세요.

브라우저에서 보던 번호가 없어진 것 같죠? 이제 각 사각형이 "1"이라고 표시됩니다. 이 문제를 해결하려면 부모 컴포넌트(Board)에서 자식 컴포넌트(Square)로 전달할 값(value)을 props를 사용하여 전달합니다.

 

Square 컴포넌트를 업데이트하여 Board에서 전달할 값(value)을 읽도록 만들어보세요.

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

`function Square({ value })`는 Square 컴포넌트에 `value`라는 prop을 전달할 수 있다는 것을 나타냅니다.

이제 각 사각형 안에 1 대신에 해당 값을 표시하려고 합니다. 다음과 같이 시도해 보세요:

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

Square 컴포넌트에서 "value" 대신 JavaScript 변수인 value를 렌더링하고 싶었습니다. JSX에서 JavaScript로 "탈출"하려면 중괄호를 사용해야 합니다. 다음과 같이 JSX에서 value 주위에 중괄호를 추가하세요:

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

 

현재 빈 보드가 표시됩니다:

이는 Board 컴포넌트가 아직 렌더링하는 각 Square 컴포넌트에 value prop을 전달하지 않았기 때문입니다. 이를 해결하기 위해 Board 컴포넌트가 렌더링하는 각 Square 컴포넌트에 value prop을 추가하겠습니다.

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

이제 다시 숫자 그리드를 볼 수 있어야 합니다:

여러분의 업데이트된 코드는 다음과 같아야 합니다:

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 내부에 handleClick이라는 함수를 선언하고, Square에서 반환한 버튼 JSX 요소의 props에 onClick을 추가하세요:

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

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

 

이제 만약 스퀘어를 클릭하면 브라우저 섹션 하단의 Console 탭에서 "clicked!"라는 메시지가 표시되어야 합니다. 스퀘어를 여러 번 클릭하면 "clicked!" 메시지가 다시 기록됩니다. 동일한 메시지로 반복된 콘솔 로그는 콘솔에서 더 많은 줄을 만들지 않습니다. 대신 첫 번째 "clicked!" 로그 옆에 증가하는 카운터를 볼 수 있습니다.

참고: 로컬 개발 환경에서 이 튜토리얼을 따라하고 있는 경우 브라우저의 콘솔을 열어야 합니다. 예를 들어 Chrome 브라우저를 사용하는 경우 콘솔은 Shift + Ctrl + J (Windows/Linux) 또는 Option + ⌘ + J (macOS) (단축키를 사용하여 열 수 있습니다.

 

다음 단계로, Square 컴포넌트가 클릭되었음을 "기억"하고 스퀘어를 "X" 표시로 채우도록 하려고 합니다. 컴포넌트가 "기억"하려면 상태를 사용합니다.

 

React는 상태를 "기억"하기 위해 컴포넌트에서 호출할 수 있는 특수한 함수인 useState를 제공합니다. 스퀘어의 현재 값을 상태로 저장하고 스퀘어가 클릭될 때 변경하겠습니다.

 

먼저 파일 맨 위에서 useState를 가져오세요. 그런 다음 Square 컴포넌트에서 value prop을 제거하세요. 대신 Square 시작 부분에 useState를 호출하는 새로운 줄을 추가하세요. 이것으로 value라는 상태 변수를 반환합니다.

import { useState } from 'react';

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

  function handleClick() {
    //...

 

value는 값을 저장하고 setValue는 값을 변경하는 데 사용할 수 있는 함수입니다. useState에 전달된 null은 이 상태 변수의 초기값으로 사용되며, 따라서 여기서 value는 null과 동일합니다.

 

Square 컴포넌트는 더 이상 props를 받지 않으므로 Board 컴포넌트에 의해 생성된 모든 9개의 Square 컴포넌트에서 value prop을 제거할 것입니다.

// ...
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에게 해당 버튼(<button>)이 클릭될 때마다 해당 Square를 다시 렌더링하도록 지시합니다. 업데이트 후에는 Square의 값이 'X'가 되므로 게임 보드에 "X"가 표시됩니다. 아무 Square를 클릭하면 "X"가 나타날 것입니다.

각 Square는 자체 상태를 가지고 있습니다. 각 Square에 저장된 값은 다른 Square와 완전히 독립적입니다. 컴포넌트에서 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>
    </>
  );
}

 

리액트 개발 도구

React DevTools를 사용하면 React 컴포넌트의 속성과 상태를 확인할 수 있습니다. React DevTools 탭은 CodeSandbox의 브라우저 섹션 하단에 있습니다.

화면에서 특정 컴포넌트를 검사하려면 React DevTools의 왼쪽 상단에 있는 버튼을 사용하세요.

참고 : 로컬 개발 환경에서는 React DevTools를 Chrome, Firefox 및 Edge 브라우저 확장 프로그램으로 사용할 수 있습니다. 이를 설치하면 React를 사용하는 웹 사이트의 개발자 도구에서 Components 탭이 나타납니다.

 

게임 완료

이 시점에서 틱택토 게임을 만들기 위한 기본 구성 요소를 모두 갖추었습니다. 이제 게임판에 번갈아 "X"와 "O"를 놓고, 승자를 결정할 방법이 필요합니다.

 

상태를 상위로 올리기

현재 각 Square 컴포넌트는 게임의 일부 상태를 유지합니다. 틱택토 게임에서 승자를 확인하려면 게임판이 각각의 9개 Square 컴포넌트의 상태를 어떻게 알아야 할까요?

 

처음에는 게임판(Board)이 각 Square로부터 해당 Square의 상태를 "물어볼" 필요가 있다고 생각할 수 있습니다. 이 접근 방식은 기술적으로 가능하지만 코드가 이해하기 어렵고 버그에 취약하며 리팩토링이 어렵기 때문에 권장하지 않습니다. 대신, 최선의 방법은 각 Square가 아닌 상위 Board 컴포넌트에 게임의 상태를 저장하는 것입니다. Board 컴포넌트는 각 Square에 숫자를 전달한 것처럼 Square에 표시할 내용을 알려주는 방식으로 동작합니다.

 

여러 개의 하위 컴포넌트에서 데이터를 수집하거나 두 하위 컴포넌트가 서로 통신해야 하는 경우, 이러한 상태를 부모 컴포넌트에서 선언하세요. 부모 컴포넌트는 해당 상태를 props를 통해 하위 컴포넌트로 다시 전달할 수 있습니다. 이를 통해 하위 컴포넌트를 서로 동기화시키고 부모와 동기화시킬 수 있습니다.

 

React 컴포넌트를 리팩토링할 때 상태를 상위 컴포넌트로 올리는 것은 흔한 패턴입니다.

 

이 기회를 활용하여 실습해 봅시다. Board 컴포넌트를 편집하여 9개의 null로 구성된 배열에 해당하는 squares라는 상태 변수를 선언하고 기본값으로 설정하세요:

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

Array(9).fill(null)는 아홉 개의 요소가 있는 배열을 만들고 각각을 null로 설정합니다. 이 배열을 초기 값으로 가지는 squares 상태 변수를 useState()를 통해 선언합니다. 배열 내 각 항목은 하나의 사각형의 값에 해당합니다. 나중에 보드를 채울 때, squares 배열은 다음과 같이 보일 것입니다:

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

 

 

이제 Board 컴포넌트는 렌더링하는 각 Square에 value prop을 전달해야 합니다:

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 컴포넌트에서 value prop을 받도록 할 것입니다. 이를 위해 Square 컴포넌트 자체의 value를 추적하는 상태와 버튼의 onClick prop을 제거해야 합니다:

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

 

이 시점에서 빈 틱택토 게임 보드를 보게 될 것입니다:

 

그리고 여러분의 코드는 다음과 같아야 합니다:

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

 

 

이제 각 Square는 'X', 'O' 또는 빈 칸인 null 값을 받게 됩니다.

 

다음으로, Square가 클릭될 때 어떤 일이 발생하는지를 변경해야 합니다. 이제 Board 컴포넌트는 어떤 칸이 채워져 있는지를 유지합니다. 따라서 Square가 Board의 상태를 업데이트할 수 있도록 하는 방법을 만들어야 합니다. 상태는 정의한 컴포넌트에 대해서만 비공개이므로 Square에서 직접 Board의 상태를 업데이트할 수는 없습니다.

 

대신, Board 컴포넌트에서 Square 컴포넌트로 함수를 전달하고, Square가 클릭되었을 때 그 함수를 호출하도록 하겠습니다. 먼저 Square 컴포넌트가 클릭될 때 호출할 함수를 만들어 보겠습니다. 이 함수를 `onSquareClick`이라고 부를 것입니다.

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

 

 

다음으로, `onSquareClick` 함수를 Square 컴포넌트의 props에 추가합니다:

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


이제 `onSquareClick` prop을 Board 컴포넌트의 `handleClick` 함수에 연결합니다. `onSquareClick`을 `handleClick`에 연결하려면 첫 번째 Square 컴포넌트의 `onSquareClick` prop에 함수를 전달합니다:

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

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

 

이로써 Square가 클릭되면 Board의 상태를 업데이트할 수 있는 방법을 마련했습니다. 이제 Square에서 클릭 이벤트를 처리하고 Board의 상태를 업데이트해 보겠습니다.

 

마지막으로, Board 컴포넌트 내부에 `handleClick` 함수를 정의하여 보드의 상태를 업데이트합니다:

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` 함수는 JavaScript의 `slice()` 배열 메서드를 사용하여 `squares` 배열의 복사본(`nextSquares`)을 생성합니다. 그런 다음 `handleClick`은 `nextSquares` 배열을 업데이트하여 첫 번째([0] 인덱스)의 사각형에 X를 추가합니다.

 

`setSquares` 함수를 호출하면 React에게 해당 컴포넌트의 상태가 변경되었음을 알립니다. 이것은 `squares` 상태를 사용하는 컴포넌트 (Board) 및 해당 하위 컴포넌트 (보드를 구성하는 Square 컴포넌트)의 다시 렌더링을 트리거합니다.

 

참고: JavaScript는 클로저를 지원하므로 내부 함수(예: `handleClick`)는 외부 함수(예: `Board`)에서 정의된 변수와 함수에 액세스할 수 있습니다. `handleClick` 함수는 `squares` 상태를 읽고 `setSquares` 메서드를 호출할 수 있습니다. 왜냐하면 두 함수 모두 `Board` 함수 내에서 정의되었기 때문입니다.

 

이제 보드에 X를 추가할 수 있게 되었지만... 왼쪽 상단의 사각형(0)만 업데이트할 수 있습니다. `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 (
    // ...
  )
}

`handleClick` 함수에 `i`를 전달해야합니다. JSX에서 직접 `onSquareClick` 프로퍼티를 `handleClick(0)`으로 설정하려고 시도할 수 있지만 작동하지 않습니다.

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

왜 이 방법이 작동하지 않는지 살펴보겠습니다. `handleClick(0)` 호출은 보드 컴포넌트를 렌더링하는 일부분입니다. `handleClick(0)`은 `setSquares`를 호출하여 보드 컴포넌트의 상태를 변경하기 때문에 보드 컴포넌트 전체가 다시 렌더링됩니다. 그러나 이로 인해 `handleClick(0)`이 다시 실행되어 무한 루프가 발생합니다.

Too many re-renders. React limits the number of renders to prevent an infinite loop.

이 문제가 이전에 왜 발생하지 않았을까요?

 

`onSquareClick={handleClick}`를 전달할 때 `handleClick` 함수 자체를 프롭으로 전달한 것이었습니다. 이 함수를 호출하지 않았습니다! 그러나 이제 해당 함수를 즉시 호출하려고 합니다. `handleClick(0)`과 같이 괄호를 사용하십시오. 이것이 바로 런타임에서 바로 해당 함수를 실행하려는 시도이며, 이것이 너무 일찍 실행되기 때문에 문제가 발생합니다. 사용자가 클릭할 때까지 `handleClick`을 호출하고 싶지 않습니다.

 

이 문제를 해결하려면 `handleClick(0)`을 호출하는 `handleFirstSquareClick`과 같은 함수를 만들고, `handleClick(1)`을 호출하는 `handleSecondSquareClick`과 같은 함수를 만드는 것입니다. 이러한 함수를 프롭으로 전달할 때 호출하지 않고 `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>
    </>
  );
};

 

이제 다시 사각형을 클릭하여 게임 보드의 아무 곳에 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>
    </>
  );
}

 

이제 상태 처리가 Board 컴포넌트로 이동했으므로 부모 Board 컴포넌트는 자식 Square 컴포넌트에 속성을 전달하여 올바르게 표시됩니다. Square를 클릭할 때 자식 Square 컴포넌트는 이제 부모 Board 컴포넌트에게 게임 보드의 상태를 업데이트하도록 요청합니다. Board의 상태가 변경되면 Board 컴포넌트와 모든 자식 Square가 자동으로 다시 렌더링됩니다. 모든 사각형의 상태를 Board 컴포넌트에 유지하는 것은 나중에 승자를 결정할 수 있게 해줄 것입니다.

 

게임 보드의 상단 왼쪽 사각형을 클릭하여 X를 추가하는 경우 어떤 일이 발생하는지 다시 요약해보겠습니다.

  1. 상단 왼쪽 사각형을 클릭하면 해당 버튼이 Square에서 onSquareClick prop으로 받은 함수를 실행합니다. Square 컴포넌트는 이 함수를 Board에서 받은 onSquareClick prop으로 받았습니다. Board 컴포넌트는 이 함수를 JSX에서 직접 정의했습니다. 이 함수는 0이라는 인수와 함께 handleClick을 호출합니다.
  2. handleClick은 인수(0)를 사용하여 squares 배열의 첫 번째 요소를 null에서 X로 업데이트합니다.
  3. Board 컴포넌트의 squares 상태가 업데이트되었으므로 Board와 모든 하위 Square가 다시 렌더링됩니다. 이로 인해 인덱스 0의 Square 컴포넌트의 value prop이 null에서 X로 변경됩니다.
  4. 최종적으로 사용자는 상단 왼쪽 사각형을 클릭한 후 빈 공간에서 X로 변경되는 것을 볼 수 있습니다.
참고: DOM `<button>` 요소의 onClick 속성은 React에 내장된 구성 요소로 특별한 의미를 가지고 있습니다. Square와 같은 사용자 정의 구성 요소의 경우 네이밍은 사용자 마음대로 정할 수 있습니다. Square의 onSquareClick prop 또는 Board의 handleClick 함수에 아무 이름을 지정해도 코드는 동일하게 작동합니다. React에서는 이벤트를 나타내는 prop에 onSomething 이름을, 해당 이벤트를 처리하는 함수 정의에는 handleSomething을 일반적으로 사용합니다.

 

왜 불변성이 중요한지

handleClick에서 기존 배열을 수정하는 대신 .slice()를 호출하여 배열의 사본을 만드는 방법에 주목하세요. 이것을 설명하려면 불변성(Immutability)과 불변성이 왜 중요한지에 대해 이야기해야 합니다.

 

데이터를 변경하는 두 가지 일반적인 방법이 있습니다. 첫 번째 방법은 데이터의 값을 직접 변경하여 데이터를 변형하는 것입니다. 두 번째 방법은 원하는 변경 내용이 있는 새로운 복사본으로 데이터를 대체하는 것입니다. 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];

배열을 직접 변형하지 않고 데이터를 변경하는 경우 다음과 같이 보입니다.

 

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 참조에서 자세히 알아볼 수 있습니다.

 

플레이어 차례 지정하기

이제 이 틱택토 게임의 주요 결함을 수정할 시간입니다. "O"는 게시판에 표시할 수 없습니다.

 

기본적으로 첫 번째 움직임을 "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가 교대로 나타납니다.

 

하지만 문제가 있습니다. 같은 사각형을 여러 번 클릭해보세요:

 

이런 식으로 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>
    </>
  );
}

 

승자 선언하기

이제 플레이어들이 번갈아 가며 플레이할 수 있게 되었으므로 게임이 이겼을 때와 더 이상 수를 둘 곳이 없을 때를 표시하고 싶을 것입니다. 이를 위해 9개의 사각형 배열을 가져와 승자를 확인하고 적절한 경우 'X', 'O' 또는 null을 반환하는 calculateWinner라는 도우미 함수를 추가할 것입니다. 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 이후에 정의하든 이전에 정의하든 상관없습니다. 매번 컴포넌트를 편집할 때마다 그 아래로 스크롤을 내려가야 하는 번거로움을 피하기 위해 마지막에 놓겠습니다.

 

Board 컴포넌트의 handleClick 함수에서 calculateWinner(squares)를 호출하여 플레이어가 이긴지 확인할 것입니다. 이 체크를 사용자가 이미 X 또는 O를 가지고 있는 사각형을 클릭했는지 확인하는 것과 동시에 수행하려고 합니다. 두 경우 모두 빨리 반환하고 싶습니다.

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

게임이 끝났을 때 플레이어에게 알릴 수 있도록 "Winner: X" 또는 "Winner: O"와 같은 텍스트를 표시할 수 있습니다. 이를 위해 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;
}

 

타임 트래블 추가

마지막으로 게임의 이전 수로 "시간을 되돌아가는" 것이 가능하도록 만들어 봅시다.

 

수의 이력 저장하기

squares 배열을 변형(mutate)하면 시간 여행 구현이 매우 어려울 것입니다.

 

그러나 매번 움직임 이후에 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'],
  // ...
]


다시 한 번 "상태를 끌어올리기"

이제 게임 이력 전체를 포함하는 history 상태를 포함할 새로운 최상위 컴포넌트인 Game 컴포넌트를 작성하겠습니다.

Game 컴포넌트로 history 상태를 넣어두면 자식 Board 컴포넌트에서 squares 상태를 제거할 수 있습니다. 마치 Square 컴포넌트에서 Board 컴포넌트로 상태를 "끌어올린 것처럼," 이제 Board 컴포넌트에서 Game 컴포넌트로 상태를 "끌어올릴" 것입니다. 이렇게 하면 Game 컴포넌트가 Board의 데이터를 완전히 제어하고 Board에게 이력에서 이전 턴을 렌더링하도록 지시할 수 있습니다.

먼저, Game 컴포넌트를 export default로 추가하고 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>
  );
}

 

주목해야 할 점은 함수 Board() { 선언 앞의 export default 키워드를 제거하고 함수 Game() { 선언 앞에 추가하는 것입니다. 이것은 index.js 파일에게 Board 컴포넌트 대신에 Game 컴포넌트를 최상위 컴포넌트로 사용하라고 알려줍니다. Game 컴포넌트에서 반환된 추가적인 div는 나중에 게임 정보에 공간을 만들기 위한 것입니다.

게임 컴포넌트에 플레이어 차례를 추적하는 상태와 움직임의 이력을 추가하십시오.

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

 

[Array(9).fill(null)]는 9 개의 null로 구성된 배열을 포함하는 항목 하나로 이루어진 배열입니다.

현재 움직임에 대한 사각형을 렌더링하려면 history에서 마지막 squares 배열을 읽어야 합니다. 이것을 계산하기 위해 useState가 필요하지 않습니다. 렌더링 중에 계산할 정보를 이미 갖고 있습니다.

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


다음으로, Board 컴포넌트에서 게임을 업데이트하기 위해 호출될 handlePlay 함수를 Game 컴포넌트 내에 만들겠습니다. xIsNext, currentSquares 및 handlePlay를 props로 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 컴포넌트가 받는 props를 통해 완전히 제어되는 Board 컴포넌트로 변경합니다. Board 컴포넌트가 xIsNext, squares 및 새로운 움직임을 만들 때 호출될 onPlay 함수 세 가지를 props로 가져야 합니다. 그런 다음 Board 함수에서 호출하는 useState의 첫 두 줄을 제거합니다.

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

 

그런 다음 Board 컴포넌트의 handleClick에서 setSquares 및 setXIsNext 호출을 새로운 onPlay 함수에 전달하여 사용자가 사각형을 클릭할 때 게임을 업데이트할 수 있도록 합니다.

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 컴포넌트는 props로 전달받은 정보에 따라 완전히 제어됩니다. Game 컴포넌트에서 게임이 업데이트되도록 handlePlay 함수를 구현해야 합니다.

handlePlay는 호출될 때 어떤 작업을 수행해야 할까요? Board는 이제 updated squares 배열을 onPlay에 전달하고 있습니다.

handlePlay 함수는 Game의 상태를 업데이트하여 다시 렌더링을 트리거해야 하지만 더 이상 호출할 수 있는 setSquares 함수가 없습니다. 이제 정보를 저장하기 위해 history 상태 변수를 사용하고 있습니다. history에 업데이트된 squares 배열을 새로운 history 항목으로 추가하여 history를 업데이트하려고 합니다. 또한 xIsNext를 토글하려고 합니다. 이전에는 Board가 이것을 수행했습니다.

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


여기서 [...history, nextSquares]는 history의 모든 항목 뒤에 nextSquares를 포함하는 새 배열을 만듭니다. (...history 확산 구문을 "history의 모든 항목을 나열한다"로 읽을 수 있습니다.)

예를 들어 history가 [[null,null,null], ["X",null,null]]이고 nextSquares가 ["X",null

,"O"]이면, 새로운 [...history, nextSquares] 배열은 [[null,null,null], ["X",null,null], ["X",null,"O"]]가 됩니다.

이 시점에서 상태를 Game 컴포넌트로 옮기고 UI는 재구성을 필요로하지 않지만 리팩토링 이전과 같이 작동해야 합니다. 이 시점에서 코드는 다음과 같아야 합니다:

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

 

과거 움직임 보여주기

지금까지 틱택토 게임의 이력을 기록했으므로 이제 플레이어에게 과거 움직임 목록을 표시할 수 있습니다.

 

<button>과 같은 React 엘리먼트는 일반적인 JavaScript 객체입니다. React에서 여러 항목을 렌더링하려면 React 엘리먼트의 배열을 사용할 수 있습니다.

 

이미 상태에서 이동 이력 배열을 가지고 있으므로, 이제 이것을 화면에 버튼을 나타내는 React 엘리먼트 배열로 변환해야 합니다. JavaScript에서 하나의 배열을 다른 배열로 변환하려면 배열 map 메서드를 사용할 수 있습니다.

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

 

맵을 사용하여 이동 이력을 화면에 버튼으로 나타낼 것이며, 과거 움직임으로 "이동"할 수 있는 버튼 목록을 표시할 것입니다. 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>
  );
}


아래에서 코드가 어떻게 보여야 하는지 볼 수 있습니다. 개발자 도구 콘솔에 "경고: 배열 또는 반복자의 각 자식은 고유한 "key" 속성을 가져야 합니다. '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;
}

Warning: Each child in a list should have a unique "key" prop.

Check the render method of `Game`. See https://reactjs.org/link/warning-keys for more information.
    at li
    at Game (https://1e4ad8f7.sandpack-bundler-4bw.pages.dev/App.js:109:53)

history 배열을 map 함수에 전달한 함수 내에서 반복하면 squares 인수는 history의 각 요소를 통과하고 move 인수는 각 배열 인덱스를 통과합니다. (대부분의 경우 실제 배열 요소가 필요하지만 움직임 목록을 렌더링하기 위해서는 인덱스 만 필요합니다.)

 

틱택토 게임의 이력에서 각 움직임에 대해 <li>를 만들어 사용합니다. 각 움직임은 버튼 <button>을 포함하는 목록 항목입니다. 버튼에는 아직 구현하지 않은 jumpTo라는 함수를 호출하는 onClick 핸들러가 있습니다.

 

지금은 게임에서 발생한 움직임 목록을 볼 수 있고 개발 도구 콘솔에 오류가 표시됩니다. "key" 오류가 무엇을 의미하는지에 대해 논의해 보겠습니다.

 

키 선택

목록을 렌더링할 때 React는 각 렌더링된 목록 항목에 대한 정보를 저장합니다. 목록을 업데이트할 때 React는 변경된 사항을 결정해야 합니다. 목록 항목을 추가, 제거, 재배열 또는 업데이트할 수 있습니다.

 

예를 들어 다음과 같이 전환한다고 가정해 보십시오.

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

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

각 계정의 업데이트된 수와 함께 사람이 이를 읽을 때 아마도 Alexa와 Ben의 순서를 바꾸고 Alexa와 Ben 사이에 Claudia를 삽입했다고 할 것입니다. 그러나 React는 컴퓨터 프로그램이며 의도를 알지 못하므로 각 목록 항목을 형제와 구분하기 위해 각 목록 항목에 대한 고유한 "key" 속성을 지정해야 합니다. 데이터가 데이터베이스에서 가져온 경우 Alexa, Ben 및 Claudia의 데이터베이스 ID를 키로 사용할 수 있습니다.

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

목록을 다시 렌더링할 때 React는 각 목록 항목의 키를 추출하고 이전 목록 항목에서 일치하는 키를 찾습니다. 현재 목록에 이전에 존재하지 않았던 키가 있는 경우 React는 컴포넌트를 생성합니다. 현재 목록에서 이전 목록에 존재했던 키가 없는 경우 React는 이전 컴포넌트를 파괴합니다. 두 키가 일치하면 해당 컴포넌트가 이동됩니다.

 

키는 React에서 각 컴포넌트의 식별을 알려주므로 React는 다시 렌더링 사이에 상태를 유지할 수 있습니다. 컴포넌트의 키가 변경되면 컴포넌트는 파괴되고 새로운 상태로 다시 생성됩니다.

 

키는 React에서 특수하고 예약된 속성입니다. 요소가 생성될 때 React는 키 속성을 추출하고 해당 키를 직접 반환된 요소에 저장합니다. 키가 props로 전달되었다는 것처럼 보이지만 React는 자동으로 키를 사용하여 업데이트할 컴포넌트를 결정합니다. 컴포넌트가 부모가 지정한 키를 요청할 수 있는 방법은 없습니다.

 

동적 목록을 작성할 때 적절한 키를 할당하는 것이 강력하게 권장됩니다. 적절한 키가 없는 경우 데이터 구조를 다시 구성하여 키를 생성하는 것을 고려할 수 있습니다.

 

키는 전역적으로 고유할 필요가 없으며 컴포넌트와 해당 형제 간에 고유하면 됩니다.

 

타임트래블 구현

틱택토 게임의 이력에서 각 이전 움직임은 해당 이동의 순차적인 번호와 연결된 고유한 ID를 갖고 있습니다. 움직임은 다시 정렬되거나 삭제되거나 중간에 삽입되지 않으므로 이동 인덱스를 키로 사용하는 것이 안전합니다.

 

Game 함수에서 키를 추가할 수 있으며 <li key={move}>와 같이 키를 사용하면, 렌더링된 게임을 다시 로드하면 React의 "key" 오류가 사라야 합니다:

const moves = history.map((squares, move) => {
  //...
  return (
    <li key={move}>
      <button onClick={() => jumpTo(move)}>{description}</button>
    </li>
  );
});


이렇게 하면 각 이동에 대한 고유한 키가 할당되어 React가 목록 항목을 올바르게 식별할 수 있게 됩니다. "key" 오류가 사라져야 하며 이제 게임의 이력을 이동할 수 있어야 합니다.

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

 

사용자가 현재 보고 있는 단계를 추적하기 위해 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];
  //...
}

다음으로, Game 내부의 jumpTo 함수를 업데이트하여 currentMove를 업데이트합니다. 또한 currentMove를 변경하는 숫자가 짝수인 경우 xIsNext를 true로 설정합니다.

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

 

이제 Game의 handlePlay 함수를 수정합니다. 이 함수는 사용자가 사각형을 클릭할 때 호출됩니다.

  • "과거로 이동"한 다음 해당 지점에서 새로운 움직임을 만드는 경우, 이전 이력을 그 지점까지만 유지하려고 합니다. 따라서 history 뒤에 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;
}

 

 

마지막 정리

매우 주의 깊게 코드를 살펴보면 currentMove가 짝수일 때 xIsNext === true이고, currentMove가 홀수일 때 xIsNext === false임을 알 수 있습니다. 다시 말해 currentMove의 값을 알고 있다면 언제든지 xIsNext가 어떤지를 알 수 있습니다.

 

상태를 저장할 때 불필요한 상태를 저장하지 않도록 노력해야 합니다. 실제로 state에 중복 정보를 저장하지 않도록 항상 노력하세요. state에 저장하는 것을 단순화하면 버그가 줄어들고 코드를 이해하기 쉬워집니다. 따라서 Game 컴포넌트에서는 xIsNext를 별도의 상태 변수로 저장하지 않고 현재 이동을 기반으로 계산하도록 변경합니다:

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

 

추가 시간이나 새로운 React 스킬을 연습하려면 틱택토 게임을 개선할 수 있는 몇 가지 아이디어를 제공합니다. 난이도가 증가하는 순서로 나열되어 있습니다:

 

  1. 현재 이동만 표시하고 "이동 #..." 대신 버튼을 표시합니다.
  2. 사각형을 하드 코딩하는 대신 두 개의 루프를 사용하여 Board를 다시 작성합니다.
  3. 오름차순 또는 내림차순으로 이동을 정렬할 수 있는 토글 버튼을 추가합니다.
  4. 승자가 있을 때 승리를 야기한 3개의 사각형을 강조 표시합니다(승자가 없을 때는 무승부 결과에 대한 메시지를 표시합니다).
  5. 이동 이력 목록에서 각 이동의 위치를 "(행, 열)" 형식으로 표시합니다.

이 자습서 동안 React의 요소, 컴포넌트, 프롭스 및 상태와 같은 개념에 대해 다루었습니다. 이러한 개념이 어떻게 동작하는지 이해했다면 "React에서 생각하기"를 확인하여 같은 React 개념이 앱의 UI를 만들 때 어떻게 동작하는지 알아보세요.