목차
1. Setup: 튜토리얼을 따라가기 위한 시작점 제공
2. Overview: React 기초인 컴포넌트, props, 상태(state)를 가르쳐 줌
3. Completing: React 개발에서 가장 일반적인 기술을 가르쳐 줌
4. Adding Time Travel: React만의 독특한 강점을 깊이 이해할 수 있도록 도와줌
1. Setup
https://codesandbox.io/s/ljg0t8?file=/App.js&utm_medium=sandpack
loving-fast-ljg0t8 - CodeSandbox
loving-fast-ljg0t8 using react, react-dom, react-scripts
codesandbox.io
2. Overview
CodeSandBox에서는 주요 세 가지 섹션이 표시된다.
1. Files : App.js, index.js, style.css 파일과 public 폴더
2. code editor : 선택한 파일의 코드를 작성할 수 있다.
3. browser : 작성한 코드가 어떻게 출력되는지 확인할 수 있다.
App.js
App.js의 코드는 컴포넌트를 생성한다. React에서 컴포넌트는 사용자 인터페이스의 일부를 나타내는 재사용이 가능한 코드이다. 컴포넌트는 애플리케이션에서 UI 요소를 렌더링, 관리 및 업데이트하는 게 사용된다.
export default function Square() {
return <button className="square">X</button>;
}
첫 번째 줄 "export default function Square()"
1. Square라는 함수를 정의하고 있다.
2. export 키워드는 이 함수가 이 파일 외부에서 접근이 가능하도록 만들며,
3. default 키워드는 이 함수는 이 파일의 주요 함수라는 것을 다른 파일에서 사용할 때 알려준다.
두 번째 줄 "return <button className="square">X</button>;"
1. <button>을 반환한다. return 키워드는 함수를 호출한 위치에 값을 반환하는 것을 의미한다.
2. <button>은 JSX요소로, JavaScript와 HTML 태그의 조합이며 어떤 내용을 표시할지를 설명한다.
3. className="square"는 버튼을 어떻게 스타일링할지를 CSS에 알려주는 버튼 속성(prop)이다.
4. X는 버튼 내부에 표시되는 텍스트이며
5. </button>은 JSX 요소를 당아서 이후의 내용이 버튼 안에 배치되지 않도록 해준다.
style.css
React 앱의 스타일을 정의한다. CSS 선택자(*, body..)는 앱의 큰 부분의 스타일을 정의하고, square 선택자는 className 속성이 square로 설정된 모든 컴포넌트의 스타일을 정의한다.
index.js
App.js에서 생성한 컴포넌트와 웹 브라우저 사이의 연결 역할(이지만 이 튜토리얼에서는 사용하지 않을 것이다.)
import React, { StrictMode } from "react"; // React
import { createRoot } from "react-dom/client"; // React DOM(React와 웹 브라우저 사아 통신 위한 라이브러리)
import "./styles.css"; // 컴포넌트 스타일링
import App from "./App"; // App.js에서 생성한 컴포넌트
나머지 파일은 이 모든 요소들을 통합하고 최종 결과물을 public 폴더의 index.html에 삽입한다.
board 만들기
App.js로 돌아가서, 여기가 바로 튜토리얼을 진행할 파일이다. 현재, board는 하나의 사각형밖에 없지만 9개가 필요하다. 만약, 이렇게 복사 붙여넣기를 하여 두 개의 사각형을 만들려고 한다면..
export default function Square() {
return
<button className="square">X</button>
<button className="square">X</button>;
}
이러한 오류가 발생한다.
React 컴포넌트는 하나의 JSX요소를 반환해야 한다. 여러 요소를 반환하고 싶다면 <></>를 사용하여 감싸주어야 한다.
export default function Square() {
return (
<>
<button className="square">X</button>
<button className="square">X</button>
</>
);
}
이제, 똑같이 복사하여 9개의 사각형을 만들어보자
사각형들이 그리드 모양이 아닌 한 줄로 나열되어 있다. <div>를 사용하여 사각형들을 묶고 CSS에서 스타일을 줘서 고쳐보자
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>
</>
);
}
.board-row:after { // board-row 클래스 내부 끝에 가상요소 생성
clear: both; // 강제 줄바꿈(양쪽 제거)
content: ''; // 내용을 지정하지 않음('')
display: table; // 이 가상 요소가 표(table) 요소처럼 동작하도록 함
}
이제 이 컴포넌트는 square라는 이름을 쓰지 않고 board라는 이름을 사용할 것이다.
Props를 통한 데이터 전달
다음으로, 사용자가 square을 클릭할 때 빈 칸 대신 "X" 값으로 변경할 것이다. 지금까지는 Board를 구성한 방식처럼 각 칸에 대한 업데이트 코드를 9번 복사 붙여넣기를 하였다면, 그 대신, React 컴포넌트 아키텍쳐를 사용하면 중복되고 지저분한 코드를 피하고 재사용 가능한 컴포넌트를 생성할 수 있다.
먼저, Board 컴포넌트에서 첫 번째 Square(<button className="square">1</button)을 새로운 Square 컴포넌트로 복사해보자.
function Square(){
return <button className="square">1</button>;
}
export default function Board() {
return (
// ...
);
}
그리고, JSX 구문을 사용하여 Board 컴포넌트에서 해당 Square 컴포넌트를 렌더링하도록 Board 컴포넌트를 업데이트 해보자.
function Square(){
return <button className="square">1</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 컴포넌트는 대문자로 시작해야 한다는 점을 다시 기억하자 : Square, Board..
이렇게 되면 각 Square에 들어가 있던 숫자가 모두 1로 통일이 되어버린다. 이를 해결하기 위하여, 부모 컴포넌트(Board)에서 자식 컴포넌트(Square)로 각각의 정사각형이 가져야 할 값을 전달하는데 props을 사용하여 보자.
Square 컴포넌트를 업데이트하여 Board에 전달되는 값을 prop으로 읽도록 업데이트한다.
function Square(value){
return <button className="square">value</button>;
}
함수인 Square(value)는 value라는 prop을 전달할 수 있다. 또한, 사각형 안에 1이 아닌 value를 표시하려면 위와 같이 작성한다.
'value'라는 변수를 얻어오려고 한 것인데 문자 그대로 'value'라고 화면에 출력되어 버렸다. JSX에서 JavaScript로 '탈출'하려면 중괄호를 사용해야 한다. JSX에서 value에 중괄호를 사용해보자.
function Square({value}){
return <button className="square">{value}</button>;
}
그러면 이렇게 빈 board가 나타나게 된다.
이는 Board 컴포넌트가 렌더링하는 각 Square 컴포넌트에 아직 값 prop을 전달하지 않았기 때문이다. 이를 해결하려면 Board 컴포넌트에서 렌더링되는 각 Square 컴포넌트에 값 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>
</>
);
인터렉티브(interactive) 컴포넌트 만들기
*interactive: 상호작용하는(사용자에게 입력 받음)
이제 Square 컴포넌트를 클릭하면 X로 채워지도록 만들어보자. Square 내부에 handleClick이라는 함수를 선언한 다음, Square에서 반환되는 button JSX 요소의 props에 onClick을 추가한다.
function Square({value}){
function handleClick(){
console.log("클릭~");
}
return (
<button
className="square"
onClick={handleClick}>
{value}
</button>
);
}
이제 사각형을 클릭하면 log에서 '클릭~'이 출력되는 것을 볼 수 있다.
다음으로는, 클릭을 했을 때 Square 컴포넌트가 사각형 안에 X를 채워놓도록 '기억'하게 해야한다. '기억'을 하게 하기 위해서는 상태(state)를 사용해야 한다.
React는 컴포넌트에서 '기억'을 할 수 있도록 useState라는 함수를 제공하고 있다. Square는 현재 값을 state에 저장하고 Square가 클릭될 때마다 값을 변경한다.
파일의 맨 위에서 useState를 import하고 그 다음, Square 컴포넌트에서 value prop을 제거한다. 대신, Square의 시작 부분에 useState를 호출하는 새로운 줄을 추가한다. 이때, state 변수를 value라 지정한다.
import { useState } from 'react';
function Square(){
const [value, setValue] = useState(null);
//....
value는 값 자체를 저장하고, setValue는 값을 변경하는 데 사용할 수 있는 함수이다. useState에 전달된 null은 이 state 변수의 초기값으로 사용된다. 즉, value는 null을 초기값으로 가지고 시작하게 된다.
Square 컴포넌트가 더이상 prop을 받지 않으므로, Board 컴포넌트에서 생성된 모든 9개의 Square 컴포넌트에서 vlaue 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'가 나오도록 바꿔보자. handleClick()함수 안에 console.log() 대신 이벤트 핸들러를 사용하여 setValue를 해주자.
import { useState } from 'react';
function Square(){
const [value, setValue] = useState(null);
//---------------------
function handleClick(){
setValue("X");
}
//---------------------
return (
<button
className="square"
onClick={handleClick}>
{value}
</button>
);
}
onClick 핸들러에서 이 set 함수를 호출함으로써, <button>이 클릭될 때마다 React는 해당 Sqaure를 다시 렌더링하도록 지시한다. 렌더링 후, Square의 값은 'X'가 되어 게임 Board에서 'X'를 볼 수 있게 된다.
각각의 Square는 자신만의 상태를 가지게 된다. value는 각 Sqaure에 완전히 독립적으로 저장되어 있다. 그리고, set 함수를 호출하였을 때, React는 자동적으로 내부의 자식 컴포넌트도 업데이트 하게 된다.
3. Completing the game
Tic-Tac-Toe 게임의 모든 기본 구성 요소를 갖추었다. 게임을 완성하기 위해 Board에 'X'와 'O'를 번갈아가며 놓을 수 있어야 하며, 승자를 결정하는 방법이 필요하다.
상태 끌어올리기(Lifting state up)
현재 각 Square 컴포넌트는 게임의 일부 상태를 유지하고 있다. 이 게임에서 승자를 확인하려면 9개의 Sqaure 컴포넌트 각각의 상태를 알아야 한다.
먼저, Board가 각 Square의 상태를 '질문'해야 한다고 생각할 수 있다. React애서는 기술적으로는 가능하나 코드가 이해하기 어려워지며 오류가 발생하기 쉽고 리팩토링하기 어려워질 수 있으므로 권장되지는 않는다. 대신, 각 Square가 아닌 부모인 Board 컴포넌트에 게임 상태를 저장하는 것이 가장 좋은 방법이다. Board 컴포넌트는 각 Square에 숫자를 전달할 때와 같이 props를 전달하여 각 Square가 무엇을 표시할 지 알려줄 수 있다.
여러 자식으로부터 데이터를 수집하거나 혹은 두 자식 컴포넌트가 서로 통신을 하려면, 부모 컴포넌트에 공유된 상태를 선언해야 한다. 그리고 그것을 자식 구성요소에 props를 통해 전달하는 것이 좋다. 이렇게 하면 자식 구성요소가 서로 혹은 부모 구성 요소와 동기화되도록 유지를 할 수 있다.
부모 구성 요소로 상태를 끌어올리는 것은 React 구성 요소가 리팩토링될 때 일반적으로 수행이 된다.
이 기회를 이용하여 시도해보자. Board 구성 요소를 수정하여 9개의 null에 해당하는 9개의 사각형을 갖는 배열로 기본 설정된 squares 라는 상태 변수를 선언한다.
function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
//...
Array(9).fill(null)은 9개의 요소의 값이 null로 초기화된 배열을 생성하였다. 이것을 useState() 함수로 감싸서 squares라는 state 변수를 선언하고 초기값으로 설정하였다. 배열의 각 원소는 각 Square의 값에 해당하며 나중에 Board를 채우면 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 컴포넌트에서 값 추적을 위한 상태와 버튼의 onClick prop을 제거해야 한다.
function Square({ value }) {
return <button className="square">{value}</button>;
}
각 Square은 이제 'X', 'O' 또는 빈 칸인 null을 prop으로 받게 된다.
다음으로, Square를 클릭했을 때 발생하는 이벤트를 변경해야 한다. Board 컴포넌트는 어떤 Square가 채워졌는지를 유지하고 있는데, 이제 Square가 Board의 상태를 업데이트하는 방법을 만들어야 한다. 상태는 정의된 컴포넌트에서만 사용이 가능하기 때문에(private) Square에서 Board의 상태를 직접 업데이트할 수는 없다.
대신, Board 컴포넌트에서 Square 컴포넌트로 함수를 전달하고, Square 컴포넌트가 클릭되었을 때 그 함수를 호출하도록 하자. 클릭될 때 호출 된 Square 컴포넌트에서 호출하는 함수인 onSquareClick 함수를 정의한 후, onSquareClick 함수를 Square의 prop으로 추가해보자.
function Square({ value, onSquareClick }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
이제 onSquareClick prop을 handleClick이라는 Board 컴포넌트의 함수와 연결한다. 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} />
// ...
);
}
마지막으로, Board 컴포넌트 내에서 handleClick 함수를 정의하여 Board의 상태를 유지하는 squares 배열을 업데이트한다.
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
function handleClick(){
// 1. squares의 복사본인 nextSquares 생성
const nextSquares = squares.slice();
// 2. handleClick을 호출하면 nextSquares 배열을 업데이트하여 첫 번째 인덱스([0])에 'X'를 추가한다.
nextSquares[0] = "X";
// 3. Squares에 nextSquares를 세팅한다.
setSquares(nextSquares);
}
// ...
}
setSquare 함수를 호출하면 React는 컴포넌트의 상태가 변경이 되었다는 것을 알게 된다. 이로 인해 squares 상태를 사용하는 컴포넌트(Board) 및 그 하위 컴포넌트(Board를 구성하는 Square 컴포넌트)가 다시 렌더링되게 된다.
이제 보드에 'X'를 추가할 수 있다. 그러나, 왼쪽 상단의 정사각형에만 추가가 된다.
handleClick 는 함수 내에서 'nextSquares[0] = "X";' 이렇게 하드코딩되어있다. 하지만, 이렇게 한다면 항상 왼쪽 상단의 정사각형만 업데이트 할 수 있으므로 다른 정사각형들은 업데이트를 할 수가 없다.
따라서 handleClick 함수를 수정하여 어떤 정사각형이든 업데이트가 가능하도록 수정해야 한다. 업데이트할 정사각형의 인덱스를 가져오는 인수 i를 handleClick 함수에 추가하여 보자.
* 하드코딩: 소스코드나 데이터를 직접 코딩하여 고정값으로 지정
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
//---------------------------------------
function handleClick(i){
const nextSquares = squares.slice();
nextSquares[i] = "X";
setSquares(nextSquares);
}
//---------------------------------------
다음으로, i를 handleClick으로 전달해야 한다. 이를 위해 JSX에서 Square의 onSquareClick prop을 직접 handleClick(0)으로 설정하려고 해도 작동하지는 않을 것이다.
<Square value={squares[0]} onSquareClick={handleClick(0)} />
작동하지 않는 이유는, handleClick(0)을 호출하는 것은 Board 컴포넌트를 렌더링하는 일부분이 된다. handleClick(0)은 setSquares를 호출하여 Board 컴포넌트의 상태를 변경하므로 전체 Board 컴포넌트가 다시 렌더링된다. 그러나 이는 handleClick(0)을 다시 실행하기 때문에 무한 루프가 발생하는 것이다.
왜 이전에는 이런 문제가 발생하지 않았냐면, onSquareClick={handleClick}을 전달할 때 handleClick 함수를 호출하지 않고 prop으로 전달하기 때문이다. 그러나 지금은 handleClick 함수를 즉시 호출하고 있다.( handleClick(0)에서 괄호를 볼 수 있음 ) 이렇게 호출하면 함수가 너무 일찍 실행되기 때문에 사용자가 클릭을 하기 전에는 handleClick을 호출해서는 안된다.
해결하는 방법으로는 handleClick(0)을 호출하는 handleFirstSquareClick과 같은 함수를 만들고, handleClick(1)을 호출하는 handleSecondClick과 같은 함수를 만드는 것이다. 그런 다음 onSquareClick={handleFirstSquareClick}과 같은 prop으로 이러한 함수를 전달한다. 이렇게 하면 무한루프가 생기는 문제를 해결할 수 있을 것이다.
하지만, 이렇게 아홉 개를 다른 함수로 정의하고 각각의 이름을 부여하는 것은 너무 복잡하다. 대신 다음과 같이 할 수 있다.
export default function Board() {
// ...
return (
<>
<div className="board-row">
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
// ...
여기서 새로운 문법인 () => 화살표 함수를 사용해보자. 이것은 함수를 정의하는 더 간결한 방법이다. 클릭할 때 => '화살표' 뒤의 코드가 실행되어 handleClick(0)을 호출한다.
이제 나머지 여덟 개의 사각형도 업데이트하여 각 handleClick 호출의 인수가 올바른 사각형의 인덱스와 일치하는지 확인해보자.
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>
//------------------------------------------------------------------------
</>
);
}
이제 어떤 사각형을 눌러도 '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 컴포넌트에게 적절한 방식으로 표시될 수 있도록 prop을 전달해야 한다. Square를 클릭하면 이제 Square(자식) 컴포넌트는 Board(부모) 컴포넌트에게 Board의 상태를 업데이트하도록 요청한다. Board의 상태가 변경되면 Board 컴포넌트와 모든 자식 Square 컴포넌트들이 자동으로 다시 렌더링된다. 모든 Square의 상태를 Board 컴포넌트에 유지하면 나중에 Board가 승자를 결정할 수 있을 것이다.
다음은 사용자가 'X'를 추가하기 위해 보드의 왼쪽 상단 사각형을 클릭하는 경우에 발생하는 일이다.
1. 상단 왼쪽의 사각형을 클릭하면 버튼이 onClick prop으로 Square에서 전달받은 함수가 실행된다.
2. Square 컴포넌트는 해당 함수를 onSquareClick prop으로 Board로부터 전달받았음.
3. Board 컴포넌트는 해당 함수를 JSX에서 직접 정의하였다. 이 함수는 0이라는 인수를 전달하여 handleClick을 호출한다.
4. handleClick 함수는 0이라는 인수를 사용하여 squares 배열의 첫 번째 요소를 null에서 X로 업데이트한다.
5. Board 컴포넌트의 squares 상태가 업데이트되었으므로 Board와 하위 컴포넌트가 다시 렌더링된다. 이로 인하여 인덱스가 0인 Square 컴포넌트의 value prop이 null에서 X로 변경된다.
6. 결국 사용자는 클릭한 상단 왼쪽 사각형이 빈 상태에서 X가 있는 상태로 변경된 것을 볼 수 있다.
* 참고
DOM <button> 요소의 onClick 속성은 내장 컴포넌트이기 때문에 React에서 특별한 의미를 가진다. Square와 같은 사용자 정의 컴포넌트의 경우 네이밍은 작성자에게 달렸다. Square의 onSquareClick prop이나 Board의 handleClick 함수에 어떤 이름을 부여하더라도 코드는 동일하게 작동한다. 그러나 React에서는 이벤트를 나타내는 prop에는 onSomething, 해당 이벤트를 처리하는 함수는 handleSomething 이름을 사용하는 것이 관례이다.
왜 불변성(Immutability)이 중요할까?
handleClick에서 기존 배열을 수정하는 대신 .slice()를 호출하여 squares 배열의 사본을 만드는 것에 주목해보자. 이것이 왜 필요한지 이해하기 위해서는 불변성이 무엇이며, 왜 중요한지에 대해 알아보아야 한다.
데이터를 변경하는 방법은 일반적으로 두 가지가 있다. 첫 번째 방법은 데이터의 값을 직접 변경하여 데이터를 변형하는 것이고, 두 번째 방법은 원하는 변경 사항이 반영된 새로운 복사본으로 데이터를 대체하는 것이다.
squares 배열을 직접 수정하는 경우:
const squares = [null, null, null, null, null, null, null, null, null];
squares[0] = 'X';
// squares : ["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];
// squares는 변경되지 않았고, nextSquares는 첫 번째 값만 'X'이다.
위의 두 가지 방법은 결과는 같지만 두 번째 방법이 여러 가지 이점이 있다.
데이터를 불변하게 유지하면 복잡한 기능을 구현하는 것이 훨씬 쉬워진다. 이 튜토리얼에서는 나중에 게임의 이력을 검토하고 이전 동작으로 '돌아갈 수 있는' '타임 트래블' 기능을 구현할 예정인데, 이 기능은 게임에 특화된 것은 아니다. 특정 작업을 실행한 후 실행 취소 및 다시 실행 기능을 제공하는 것은 일반적으로 앱에서 요구하는 사항이다. 직접 데이터를 변이시키지 않으면 데이터의 이전 버전을 유지하고 나중에 재사용할 수 있다.
또 다른 이점은, 기본적으로 부모의 컴포넌트 상태가 변경될 때 모든 하위 자식 컴포넌트도 모두 자동으로 다시 렌더링 된다. 이때, 이 변경에 영향을 받지 않은 하위 컴포넌트도 포함되게 된다. 재렌더링 자체는 사용자에게 눈에 띄지는 않는다.(반드시 피하려고 노력할 필요는 없다.) 그러나, 성능상 이유로 변경에 영향을 받지 않은 트리 일부를 건너뛰려는 경우도 있다. 이때 불변성은 컴포넌트가 데이터가 변경되었는지 여부를 비교하는 것을 매우 저렴(cheap)하게 만든다.
* cheap하게 만든다? : 비용이 적게 듦. 즉, 불변성을 유지함으로써 컴포넌트가 자신의 데이터가 변경되었는지 비교하는 것이 매우 빠르게 이루진다는 의미. 따라서 불필요한 렌더링을 줄이고 성능을 향상키실 수 있다.
차례 정하기
이제, 이 게임의 주요 결함을 수정해보자. 현재, 게임판에는 'O'가 표시되지 않는다.
기본적으로, 첫 번째 클릭은 'X'로 설정하고, Board 컴포넌트에 또 다른 상태를 추가하여 이를 추적해 보자.
export default function Board() {
const [xIsNext, setXIsNext] = useState(true);
const [squares, setSquares] = useState(Array(9).fill(null));
// ...
각 플레이어가 움직일 때마다 xIsNext(boolean)가 반전되어 다음 플레어가 누가 되는지를 결정하고 게임의 상태가 저장된다. 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();
// xIsNext의 값이 true라면 nextSquares i번째 값이 "X"가 되고 false라면 "O"가 됨
if(xIsNext) {
nextSquares[i] = "X";
} else {
nextSquares[i] = "O";
}
/// squares 상태 업데이트
setSquares(nextSquares);
// xIsNext 값을 현재 xIsNext값의 반대로 세팅하여 다음 플레이어가 다음 턴에 움직일 수 있도록 함
setXIsNext(!xIsNext);
}
// ...
이제 클릭할 때마다 사각형에 X와 O가 번갈아가며 나타나고 있다.
그러나, 같은 사각형을 여러번 클릭했을 때 아래와 같이 X 혹은 O가 있는 사각형이 다른 값으로 바뀐다. 즉, 여러 번 같은 칸을 클릭하면 X나 O 중에 마지막으로 클릭한 것이 해당 칸에 저장되게 된다. 처음 사각형을 클릭하면 X 혹은 O가 나타나고 그 뒤에 클릭을 하더라도 변경이 되지 않도록 고쳐보자.
현재 X나 O가 있는 사각형을 클릭했을 때 사각형이 X나 O로 이미 채워져 있는지를 확인하고 있지 않다. 이를 고치기 위하여 먼저 반환(return)하는 방법을 사용한다. handleClick 함수에서 해당 칸이 이미 X나 O로 채워져 있는지를 확인한다. 만약 이미 채워져 있다면, 게임판의 상태를 업데이트하기 전 handleClick 함수에서 일찍 반환(return)을 한다.
export default function Board() {
// ...
function handleClick(i) {
if(squares[i]) {
return;
}
// ...
이제 X나 O가 빈 사각형에만 채워지는 것을 확인할 수 있다.
승자 정하기
이제 플레이어가 차례대로 진행을 할 수 있게 되었으니 게임이 이겼는지, 게임이 진행될 차례가 남았는지를 보여주어야 한다. 이를 위해 9개의 사각형을 배열로 입력받고 승자가 있는지 확인하여 적절한 값을 반환하는 calculateWinner라는 도우미 함수를 추가할 것이다. (이 함수는 React에 특화된 함수는 아니다.)
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;
}
Board 컴포넌트의 handleClick 함수에서 calculateWinner(squares)를 호출하여 플레이어가 승리했는지를 확인할 것이다. 이 확인을 수행하는 동시에 사용자가 이미 X 또는 O로 채워진 사각형을 클릭했는지 확인할 수 있다.
export default function Board() {
// ...
if(squares[i] || calculateWinner(squares)) {
return;
}
// ...
게임이 끝났을 때 플레이어에게 "승자: X or O"와 같은 텍스트를 표시하여 게임이 끝났음을 알리려면 Board 컴포넌트에 상태를 추가하여야 한다. 상태는 게임이 끝나면 승자를 표시하고 게임이 진행 중인 경우 다음 턴의 플레이어를 표시한다.
export default function Board() {
// ...
//--------------------------------------
const winner = calculateWinner(squares);
let status;
if(winner){
status = "승자: " + winner;
} else {
status = "다음 플레이어: " + (xIsNext ? "X" : "O");
}
//--------------------------------------
// ....
return (
<>
//--------------------------------------
<div className="status">{status}</div>
//--------------------------------------
// ...
완성!
<전체 코드>
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));
const winner = calculateWinner(squares);
let status;
if(winner){
status = "승자: " + winner;
} else {
status = "다음 플레이어: " + (xIsNext ? "X" : "O");
}
function handleClick(i) {
if(squares[i] || calculateWinner(squares)) {
return;
}
const nextSquares = squares.slice();
if (xIsNext) {
nextSquares[i] = "X";
} else {
nextSquares[i] = "O";
}
setSquares(nextSquares);
setXIsNext(!xIsNext);
}
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;
}
출처
https://react.dev/learn/tutorial-tic-tac-toe
Tutorial: Tic-Tac-Toe – React
The library for web and native user interfaces
react.dev
4. Adding time travel
마지막으로 게임에서 이전의 수로 돌아가는 기능을 추가해보자.
수의 기록을 저장하기
만약 squares 배열을 직접적으로 수정한다면 시간여행을 구현하는 것은 매우 어려울 것이다.
그러나 위에서 언급했 듯(불변성) 우리는 squares 배열의 새로운 복사본을 만들기 위해 slice()를 사용하고, 이를 불변성으로 처리하였다. 이를 통해, 이미 진행된 턴들 사이를 이동하며 이전 모든 버전의 squares 배열을 저장할 수 있다.
이제 이전 squares 배열의 모든 기록을 history라는 새로운 state 변수에 저장할 것이다. history 배열은 첫 번째 이동부터 마지막 이동까지 모든 Board의 상태를 나타내며 다음과 같다.
[
// 첫 번째 수 전
[null, null, null, null, null, null, null, null, null],
// 첫 번째 수 후
[null, null, null, null, 'X', null, null, null, null],
// 두 번째 수 후
[null, null, null, null, 'X', null, null, null, 'O'],
]
다시, 상태 끌어올리기(Lifiting State up)
이제 게임의 모든 이력을 포함하는 history 상태를 배치할 새로운 최상위 컴포넌트인 Game을 작성해보자.
history 상태를 Game 컴포넌트에 배치하면, 그것을 통해 Board 컴포넌트인 squares state를 제거할 수 있다. 마치 Square 컴포넌트에서 Board 컴포넌트로 state를 '올리듯이' 이번에는 Board 컴포넌트에서 최상위 수준의 Game 컴포넌트로 state를 올릴 것이다. 이렇게 하면 Game 컴포넌트가 Board의 데이터를 완전히 제어할 수 있으며 이전 턴을 history에서 가져와 Board에 렌더링하도록 지시할 수 있다.
먼저 export defalut를 가진 Game 컴포넌트를 추가한 후 이 컴포넌트에서 Board 컴포넌트와 일부 마크업을 렌더링해보자.
// Board(){} 에 있는 export default는 지운다.
export default function Game(){
return (
<div className="game">
<div className="game-board">
<Board />
</div>
<div>
<ol>{/* TO DO */}</ol>
</div>
</div>
);
}
Board() 함수와 함께 선언된 export default 키워드를 제거하고 Game() 함수 선언 앞에 추가를 해야 한다. 이렇게 함으로써 index.js 파일이 Board 컴포넌트 대신에 Game 컴포넌트를 최상위 컴포넌트로 사용하도록 지시한다. 게임 컴포넌트가 반환하는 추가적인 div는 나중에 Board에 추가할 게임 정보를 위한 공간을 만들어준다,
Game 컴포넌트에 어떤 플레이어가 다음 차례인지와 게임의 기록을 추적하는 xIsNext, histroy state를 추가해보자.
export default function Game(){
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState(Array(9).fill(null));
return (
// ...
Array(9).fill(null)은 하나의 항목을 가진 배열이며, 그 항목 자체가 9개의 null을 가진 배열이다.
현재 상태의 표시를 위하여 Board를 렌더링할 때, 현재 상태를 계산할 수 있는 충분한 정보를 이미 갖고 있기 때문에 useState를 사용하지 않아도 된다. 이전에 저장된 history에서 마지막 요소인 배열을 현재 상태로 계산하여 렌더링하면 된다.
export default function Game(){
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState(Array(9).fill(null));
//-----------------
// history배열의 마지막 요소에 최근 저장된 게임판 상태를 currentSquares 변수에 할당
const currentSquares = history[history.length -1];
//-----------------
다음으로 Board 컴포넌트 내에 게임을 업데이트하기 위해 호출될 handlePlay 함수를 만들고 xIsNext, currentSquares, handlePlay를 Board 컴포넌트에 prop로 전달해보자.
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 컴포넌트를 수정하여 세 개의 props(xIsNext, squares, onPlay)를 받도록 변경하고, 플레이어가 움직임을 만들 때 업데이트된 squares 배열을 전달할 수 있는 새로운 onPlay 함수를 추가한다. 그 다음, useState를 호출하는 Board 함수의 첫 두 줄을 제거한다.
function Board({xIsNext, squares, onPlay}) {
// const [xIsNext, setXIsNext] = useState(true);
// const [squares, setSquares] = useState(Array(9).fill(null)); --> 삭제
이제 Board 컴포넌트에서 handleClick 함수 내부의 setSqaures와 setXIsNext 호출을, 새로운 onPlay 함수를 한 번 호출하도록 변경하여 사용자가 사각형을 클릭할 때 Game 컴포넌트가 Board를 업데이트할 수 있도록 한다.
function Board({ xIsNext, squares, onPlay }) {
const winner = calculateWinner(squares);
let status;
if (winner) {
status = "승자: " + winner;
} else {
status = "다음 플레이어: " + (xIsNext ? "X" : "O");
}
function handleClick(i) {
if (squares[i] || calculateWinner(squares)) {
return;
}
const nextSquares = squares.slice();
if (xIsNext) {
nextSquares[i] = "X";
} else {
nextSquares[i] = "O";
}
//-------------------------------------
onPlay(nextSquares);
//-------------------------------------
}
Board 컴포넌트는 Game 컴포넌트에서 전달된 props에 의해 완전히 제어된다. 따라서 Game 컴포넌트에서 handlePlay 함수를 구현하여 게임을 다시 작동시켜야 한다.
handlePlay 함수가 호출될 때 어떻게 동작해야 하는지 기억해보자. 이전에 Board 컴포넌트는 업데이트된 배열을 setSqaures로 전달하였다. 이제는 업데이트된 squares 배열을 onPlay로 전달한다.
handlePlay 함수는 게임을 다시 렌더링하도록 Game 컴포넌트의 state를 업데이트해야 한다. 이제 더이상 setSqaures 함수를 호출할 수 없으므로 history state 변수를 사용하여 이 정보를 저장한다. 업데이트된 squares 배열을 새로운 histroy 항목으로 추가하여 histroy를 업데이트하고, 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"]]가 된다.
이 시점에서 state가 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 (squares[i] || calculateWinner(squares)) {
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;
}else {
status = '다음 플레이어: ' + (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-">
<ol>{/* TO DO */}</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;
}
과거의 움직임 보여주기
이제 게임의 history를 기록하므로 이제 플레이어에게 과거에 움직인 목록을 표시할 수 있다.
<button>과 같은 React 요소는 일반적인 JavaScript 객체이다. 애플리케이션에서 이러한 요소를 전달할 수 있으며, React에서 여러 항목을 렌더링하려면 React 요소의 배열을 사용할 수 있다.
이미 state에 histroy 움직임의 배열이 있으므로 이제 이를 React 요소의 배열로 변환해야 한다. JavaScript에서 한 배열을 다른 배열로 변환하려면 배열의 map 메서드를 사용할 수 있다.
[1, 2, 3].map((x) => x * 2) // 결과: [2, 4, 6]
history의 움직임을 React 요소로 변환하고, 화면 상에 버튼을 나타내는데 map 메서드를 사용하고, 이전의 움직임으로 "이동"할 수 있는 버튼 목록을 표시한다. Game 컴포넌트에서 history를 map한다.
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 = "움직임 #" + move;
} else {
description = "게임 시작";
}
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;
}
map 함수로 history 배열을 반복하면서 넘겨진 함수 내에서, squares 인수는 history의 각 요소를 통과하고 move 인수는 0, 1, 2등의 각 배열 인덱스를 통과한다.(대부분의 경우 실제 배열 요소가 필요하나 이동 목록을 렌더링하는 경우 인덱스만 필요하다.)
게임의 이력에서 각각의 이동마다 <li> 리스트 항목을 만들고, 버튼 <button>이 포함되도록 한다. 버튼은 아직 구현하지 않은 jumpTo 함수를 호출하는 onClick 핸들러를 가지고 있다.
현재 게임에서 발생한 이동 목록이 나열된 리스트를 볼 수 있으며 개발자 도구 콘솔에는 오류가 발생할 것이다. 다음으로 'key' 오류가 무엇을 의미하는지 알아보자.
고유한 식별을 위한 값 선택(Picking a key)
리스트를 렌더링할 때, React는 각 렌더링된 리스트 아이템에 대한 정보를 저장한다. 리스트를 업데이트할 때, React는 변경된 사항을 결정해야 한다. 추가, 삭제, 재배열 또는 업데이트된 아이템이 있을 수 있다.
예를 들어, 현재 상황에서 리스트 아이템들은 게임에서 수행된 이동들을 나타내며, 이동들이 추가될 때마다 리스트가 업데이트된다. 각 이동은 인덱스 번호를 갖고 있으며 이 번호를 사용하여 이동을 선택할 수 있다. 그러나 이동 리스트를 업데이트하면서 React는 렌더링된 이동 리스트 아이템에 대한 정보를 유지해야 한다. 이를 위해 각 이동 리스트 아이템에 대한 고유한 key를 지정해야 한다.
다음과 같은 상황을 상상해보자.
<li>김흥부 : 7개 남음</li>
<li>김놀부 : 5개 남음</li>
에서
<li>김놀부 : 5개 남음</li>
<li>박까치 : 8개 남음</li>
<li>김흥부 : 9개 남음</li>
각 리스트 항목을 구분하기 위하여 각 항목에 대한 key 속성을 지정해야 한다. 이를 작성한 사람은 바뀐 개수 외에도 김흥부와 김놀부의 순서를 바꾸고, 박까치를 김흥부와 김놀부 사이에 삽입했다고 할 것이다. 그러나 React는 컴퓨터 프로그램으로 의도를 알 수 없으므로 각 리스트의 항목을 형제 항목과 구분하기 위하여 key 속성을 지정해야 한다. 데이터가 데이터베이스에서 가져온 것이라면 이 세 사람의 데이터베이스 ID를 key로 사용할 수 있다.
<li key={user.id}>
{user.name}: {user.taskCount}개 남음
</li>
리스트가 재랜더링될 때, React는 각각의 리스트 아이템을 key로 가져와 이전 리스트 아이템들 중에 일치하는 key를 찾는다. 만약 현재 리스트에 이전 리스트에 없던 key가 있다면, React는 새로운 컴포넌트를 생성할 것이다. 만약, 현재 리스트에 이전리스트에 있던게 없다면 React는 이전 컴포넌트를 파괴한다. 그리고 두 개의 키가 일치한다면 해당 컴포넌트가 이동한다.
1. 리스트에 없던 key -> 생김 : 새로운 컴포넌트 생성
2. 리스트에 있었던 key -> 없음: 이전 컴포넌트 파괴
3. 두 개의 key 일치 -> 해당 컴포넌트가 이동
key는 각 컴포넌트의 식별자 역할을 하며, React가 재랜더링 사이에서 상태를 유지할 수 있도록 한다. 만약 컴포넌트의 key가 변경된다면 컴포넌트는 파괴되고 새로운 상태로 다시 생성되게 된다.
key는 React에서 특특별하며 예약된 속성이다. 요소가 생성되면 React는 key 속성을 추출하고 반환된 요소에 직접 key를 저장한다. key는 props로 전달되는 것처럼 보일 수 있으나, React는 자동으로 key를 사용하여 업데이트할 컴포넌트를 결정한다. 컴포넌트가 부모가 지정한 key를 요청할 방법은 없다.
동작 목록을 빌드할 때 적잘한 key를 할당하는 것이 강력하게 권장된다. 적절한 key가 없는 경우 데이터를 재구성하여 키를 할당해야 한다.
만약 key가 지정되지 않으면 React는 에러를 보고하고 배열 인덱스를 기본값으로 key를 사용한다. 배열 인덱스를 키로 사용하는 것은 리스트 항목의 순서를 바꾸거나 항목을 삽입/제거할 때 문제가 발생한다. key={i}를 명시적으로 전달하면 에러는 사라지지만 배열의 인덱스와 동일한 문제가 있으므로 대부분의 경우엔 권장되지는 않는다.
key는 전역적으로 고유할 필요가 없으며 컴포넌트와 해당 형제 컴포넌트 사이에서만 고유해야 한다.
시간여행 구현하기
게임의 기록에서 각 이전 동작은 해당하는 고유한 ID를 가지며, 이는 해당 동작의 순차적인 번호이다. 동작은 절대 재배열, 삭제, 삽입되지 않으므로, 동작 인덱스를 key로 사용하는 것이 안전하다.
Game 함수에서 key를 <li key={move}>와 같이 추가할 수 있다. 그리고 렌더링된 게임을 새로 고치면 React의 "key"오류가 사라진다.
export default function Game() {
// ...
return (
//----------------
<li key={move}>
//----------------
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});
jumpTo를 구현하기 전에 사용자가 현재 보는 단계를 Game 구성 요소가 추적하도록 해야 한다. 이를 위해 기본값이 0인 currentMove라는 새로운 상태 변수를 정의해야 한다.
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);
// 결과가 짝수이면 true, 홀수이면 false
}
//...
이제 클릭한 사각형을 처리하는 Game의 handlePlay 함수를 두 가지 변경할 것이다.
1. 과거로 이동한 다음 해당 지점에서 새로운 움직임을 만들 경우 해당 지점까지의 history만 유지하려한다. 따라서 nextSquares를 모든 항목(...~~) 뒤가 아닌 history.slice(O, currentMove + 1)의 모든 항목 뒤에 추가한다.
2. 매번 움직임이 만들어질 때마다 가장 최근의 history 항목을 가리키도록 currentMove를 업데이트해야 한다.
export default function Game() {
//...
function handlePlay(nextSquares) {
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
setXIsNext(!xIsNext);
}
//...
마지막으로 마지막으로 움직인 것을 렌더링하는 대신, 선택된 이동을 렌더링하도록 Game 컴포넌트를 수정해야 한다. 이를 위해 render 메서드에서 다음과 같은 변경이 필요하다.
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];
//-----------------------------------------
만약 게임의 기록에서 어떤 단계를 클릭한다면 해당 단계 이후의 보드가 즉시 업데이트되어 보여져야 한다.
마지막 정리
코드를 자세하게 본다면 currentMove가 짝수일 때 xIsNext === true이고, currentMove가 홀수일 때 xIsNext === false임을 알 수 있다. 다시 말해, currentMove의 값을 알고 있다면 언제나 xIsNext가 무엇인지 알 수 있다.
따라서 두 가지 모두를 상태에 저장할 필요는 없는 것이다. 중복되는 상태는 항상 피하는 것이 좋기 때문이다. 저장하는 것을 단순화하면 버그가 줄고 코드의 이해도 쉬워진다. Game이 xIsNext를 별도로 상태 변수로 저장하지 않고 currentMove를 기반으로 계산하도록 변경해보자.
export default function Game() {
const [history, setHistory] = useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = useState(0);
// const [xIsNext, setXIsNext] = useState(true); ---> 삭제
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);
// setXIsNext(!xIsNext); --> 삭제
}
function jumpTo(nextMove) {
setCurrentMove(nextMove);
// setXIsNext(nextMove % 2 === 0); --> 삭제
}
// ...
xIsNext 상태 선언과 setXIsNext의 호출이 더이상 필요하지 않다. 이제 컴포넌트를 코딩하는 동안 실수를 하더라도 xIsNext가 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 (squares[i] || calculateWinner(squares)) {
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;
} else {
status = "다음 플레이어: " + (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, setXIsNext] = useState(true); ---> 삭제
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);
// setXIsNext(!xIsNext); --> 삭제
}
function jumpTo(nextMove) {
setCurrentMove(nextMove);
// setXIsNext(nextMove % 2 === 0); --> 삭제
}
const moves = history.map((squares, move) => {
let description;
if (move > 0) {
description = "움직임 #" + move;
} else {
description = "게임 시작";
}
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;
}
'JS > React' 카테고리의 다른 글
6. React 프로젝트 만들기 (0) | 2023.05.22 |
---|---|
5. React? (0) | 2023.05.21 |
4. Node.js (0) | 2023.05.21 |
2. React Quick Start - React.dev 정리2 (0) | 2023.05.09 |
1. React Quick Start - React.dev 정리 (0) | 2023.05.08 |