React 앱은 컴포넌트로 만들어집니다. 컴포넌트는 고유한 로직과 모양을 가진 UI(사용자 인터페이스)의 일부입니다. 컴포넌트는 버튼만큼 작을 수도 있고 전체 페이지만큼 클 수도 있습니다.
function MyButton() {
return (
<button>I'm a button</button>
);
}
너무 당연하게 button 태그를 사용하지만, 어떻게 이게 가능할까요?
MyButton
함수는 JSX 문법을 사용하여 작성된 React 컴포넌트입니다. 이 컴포넌트를 실행하면 React 내부에서 어떻게 동작하는지 간략히 설명해보겠습니다.
JSX 변환: JSX는 JavaScript XML의 약어로, React 엘리먼트를 생성하기 위한 문법입니다. 컴파일러나 바벨과 같은 도구를 사용하여 JSX 코드는 일반적인 JavaScript 코드로 변환됩니다. 이 변환 단계에서 MyButton
함수 내의 JSX 코드도 일반적인 JavaScript 코드로 변환됩니다.
function MyButton() {
return React.createElement(
"button",
null,
"I'm a button"
);
}
컴포넌트 함수 호출: MyButton
함수가 호출되면, 해당 함수 내의 코드가 실행됩니다.
React.createElement
: React.createElement
함수는 React 엘리먼트를 생성하는 역할을 합니다. 이 함수는 첫 번째 인자로 생성할 엘리먼트의 타입을 받고, 두 번째 인자로는 엘리먼트의 속성 (props)을 받습니다. 세 번째 이후의 인자는 엘리먼트의 자식 노드로 사용됩니다.
위에서 변환된 코드에서는 React.createElement
함수를 사용하여 버튼 엘리먼트를 생성하고, 내부에 텍스트 "I'm a button"을 포함시켰습니다.
가상 DOM 생성: React.createElement
함수를 통해 생성된 엘리먼트는 가상 DOM (Virtual DOM)의 구조를 형성합니다. 가상 DOM은 실제 DOM과 동기화되는 작업을 최소화하고, 효율적으로 업데이트를 수행하기 위한 추상화 계층입니다.
실제 DOM 업데이트: 가상 DOM은 React의 조화 (Reconciliation) 과정을 통해 실제 DOM과 비교되며 업데이트가 필요한 부분을 판단합니다. 변경된 부분만을 선택적으로 업데이트하여 성능을 최적화합니다.
화면에 렌더링: 최종적으로 업데이트된 내용이 실제 브라우저 화면에 렌더링됩니다.
요약하자면, JSX로 작성된 React 컴포넌트는 함수 호출을 통해 React 엘리먼트를 생성하고, 이를 가상 DOM을 통해 실제 DOM으로 업데이트하여 화면에 렌더링하는 과정을 거치게 됩니다.
React는 디자인을 바라보는 방식과 앱을 빌드하는 방식을 바꿀 수 있습니다. React로 사용자 인터페이스를 빌드할 때는 먼저 컴포넌트라고 하는 조각으로 분해합니다. 그런 다음 각 컴포넌트에 대해 서로 다른 시각적 상태를 기술합니다. 마지막으로 컴포넌트를 서로 연결해 데이터가 흐르도록 합니다.
저는 스터디 자료가 복습의 용도가 되길 희망하는 마음으로 자료를 만들었습니다.
https://codesandbox.io/p/sandbox/xenodochial-phoebe-yymhlt
소스의 각주인 step 별로 확인하시면 됩니다.
TicTacToe게임을 만들어 진행해보겠습니다.
먼저 목업의 모든 컴포넌트와 하위 컴포넌트 주위에 상자를 그리고 이름을 지정합니다
JSON이 잘 구조화되어 있으면 UI의 컴포넌트 구조에 자연스럽게 매핑되는 것을 종종 발견할 수 있습니다. 이는 UI와 데이터 모델이 동일한 정보 아키텍처, 즉,동일한 형태를 가지고 있는 경우가 많기 때문입니다. UI를 컴포넌트로 분리하면 각 컴포넌트가 데이터 모델의 한 부분과 일치합니다.
하나의 게임이라는 기능을 할 수 있도록 메인이라는 컴포넌트를 만들겠습니다.
그리고 상태를 나타내주는 상태바, 게임을 진행할 보드, 게임의 결과를 기록하고 이동할 수 있는 히스토리로 구조를 나눕니다.
보드에는 각각 box가 버튼을 클릭하면 ‘X’나 ‘O’ 나타내는 기능을 하게되므로 세분하여 나눕니다.
물론 더 세분하거나, 덜 세분하여 나눌 수 있습니다.
정리하면,
메인 컴포넌트
const Game = () => {
return (
<>
<div className="game">
<div className="game-board">
<Status />
<Board />
</div>
<History />
</div>
</>
);
};
상태바 컴포넌트
const Status = () => {
return <div className="status">nextUser is X</div>;
};
보드 컴포넌트
const Board = () => {
return (
<>
<div className="board-row">
<Box />
<Box />
<Box />
</div>
<div className="board-row">
<Box />
<Box />
<Box />
</div>
<div className="board-row">
<Box />
<Box />
<Box />
</div>
</>
);
};
보드의 내부인 Box컴폰넌트
const Box = () => {
return <button className="square"></button>;
};
히스토리 컴포넌트
const History = () => {
const historyList: Array<number> = new Array(9).fill(0);
return (
<ol>
{historyList?.map((history, idx) => (
<li key={idx}>
<button>
{idx !== 0 ? "Go to move #" + idx : "Go to gameStart "}
</button>
</li>
))}
</ol>
);
};
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.board-row:after {
clear: both;
content: '';
display: table;
}
.status {
margin-bottom: 10px;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
일단 정적인 UI로 컴포넌트를 만듭시다! 일단 정적인 컴포넌트를 만들고, 상호작용할 수 있는 기능을 부가적으로 붙이도록 하겠습니다.
데이터 모델을 렌더링하는 앱의 정적 버전을 만들기 위해서는 다른 컴포넌트를 재사용하는 components를 만들고 props를 사용하여 데이터를 전달해야 합니다. 정적인 컴포넌트를 만들땐, state를 사용하지말고, props만 이용해서 만들도록 합시다. 이 앱은 정적 버전이므로 필요하지 않습니다.
전체적인 게임 상황과 변동사항을 관리하는 곳은 메인 컴포넌트입니다.
리액트에서 데이터는 아래로 흐른다는 것을 주의해서 처음 데이터가 있을 곳을 결정하도록합니다.
메인 컴포넌트에서 게임 상태를 나타내는
const user = "X";
const historyList: Array<Array<boxValues>> = [new Array(9).fill(null)];
const squares: Array<boxValues> = new Array(9).fill(null);
const gameStatus = "ing";
user, historyList, square, gameStatus를 만들어서 각각 컴포넌트로 뿌려주도록 합니다.
게임 상황을 나타내는 상태바에서는 gameStatus과 user를 props를 받습니다.
보드를 나타내는 컴포넌트에서는 모든 box에 들어갈 squares를 받습니다.
박스에서는 보드에서 온 box에 들어갈 squares[idx]하나하나를 받아서 넣을 수 잇도록 각각 props로 받습니다.
히스토리 리스트 컴포넌트에서는 historyList를 Game컴포넌트에서 props로 받아옵니다.
props만을 추가하도록 합니다.
const Game = () => {
const user = "X";
const historyList: Array<Array<boxValues>> = [new Array(9).fill(null)];
const squares: Array<boxValues> = new Array(9).fill(null);
const gameStatus = "ing";
return (
<>
<div className="game">
<div className="game-board">
<Status gameStatus={gameStatus} user={user} />
<Board squares={squares} />
</div>
<History historyList={historyList} />
</div>
</>
);
};
const Status = ({
gameStatus,
user,
}: {
gameStatus: "ing" | "end";
user: string;
}) => {
return (
<div className="status">
{(gameSituation[gameStatus] ? "nextUser is " : "winner is ") + user}
</div>
);
};
const Box = ({ value }: { value: boxValues }) => {
return <button className="square">{value}</button>;
};
const Board = ({ squares }: { squares: Array<boxValues> }) => {
return (
<>
<div className="board-row">
<Box value={squares[0]} />
<Box value={squares[1]} />
<Box value={squares[2]} />
</div>
<div className="board-row">
<Box value={squares[3]} />
<Box value={squares[4]} />
<Box value={squares[5]} />
</div>
<div className="board-row">
<Box value={squares[6]} />
<Box value={squares[7]} />
<Box value={squares[8]} />
</div>
</>
);
};
const History = ({ historyList }: { historyList: Array<Array<boxValues>> }) => {
return (
<ol>
{historyList?.map((history, idx) => (
<li key={idx}>
<button>
{idx !== 0 ? "Go to move #" + idx : "Go to gameStart "}
</button>
</li>
))}
</ol>
);
};
UI를 상호작용하게 만들려면 사용자가 기반이 되는 데이터 모델을 변경할 수 있도록 해야 합니다. 이를 위해 state를 사용합니다.
state를 앱이 기억해야 하는 최소한의 변화하는 데이터 집합으로 생각하세요. state를 구조화할 때 가장 중요한 원칙은 DRY(직접 반복하지 않기)를 유지하는 것입니다. 애플리케이션에 필요한 최소한의 state를 파악하고 그 외의 모든 것을 필요할 때 계산하세요. 예를 들어, 쇼핑 목록을 작성하는 경우 항목을 state 배열로 저장할 수 있습니다. 목록에 있는 항목의 개수도 표시하려면 항목의 개수를 다른 state 값으로 저장하는 대신 배열의 길이를 읽으면 됩니다.
이제 이 예제 애플리케이션의 모든 데이터 조각을 생각해 보세요
const [count setCount] = useState(0)
const [user,setUser] = useState("X");
const [gameStatus, setGameStatus] = useState<"ing" | "end">("ing");
const [squares, setSquares] = useState(new Array(9).fill(null));
const [history, setHistory] = useState<Array<Array<boxValues>>>([
Array(9).fill(null),
]);
const calculateWinner = (squares: Array<boxValues>) => {
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 "end";
}
}
return "ing";
};
const Game = () => {
const [count, setCount] = useState(0);
const [user, setUser] = useState<"O" | "X">("X");
const [gameStatus, setGameStatus] = useState<"ing" | "end">("ing");
const [squares, setSquares] = useState(new Array(9).fill(null));
const [history, setHistory] = useState<Array<Array<boxValues>>>([
Array(9).fill(null),
]);
const handlePlay = (nextSquares: Array<boxValues>) => {
setSquares(nextSquares);
const nextHistory = [...history.slice(0, count + 1), nextSquares];
setGameStatus(calculateWinner(nextSquares));
setHistory(nextHistory);
setCount(nextHistory.length - 1);
setUser((count + (gameStatus === "ing" ? 0 : 1)) % 2 === 0 ? "O" : "X");
};
const handleHistory = (idx: number) => {
setSquares(history[idx]);
const nextHistory = [...history.slice(0, idx + 1)];
setHistory(nextHistory);
setCount(idx + 1);
};
return (
<>
<div className="game">
<div className="game-board">
<Status gameStatus={gameStatus} user={user} />
<Board squares={squares} onPlay={handlePlay} user={user} />
</div>
<History historyList={history} onClick={handleHistory} />
</div>
</>
);
};
const Board = ({
squares,
onPlay,
user,
}: {
squares: Array<boxValues>;
onPlay: (squares: Array<boxValues>) => void;
user: "O" | "X";
}) => {
const [gameStatus, setGameStatus] = useState("ing");
const handleClick = (idx: number) => {
setGameStatus(calculateWinner(squares));
if (calculateWinner(squares) === "end" || squares[idx]) return;
const newSquares = [...squares];
newSquares[idx] = user;
onPlay(newSquares);
};
return (
<>
<div className="board-row">
<Box value={squares[0]} onClick={() => handleClick(0)} />
<Box value={squares[1]} onClick={() => handleClick(1)} />
<Box value={squares[2]} onClick={() => handleClick(2)} />
</div>
<div className="board-row">
<Box value={squares[3]} onClick={() => handleClick(3)} />
<Box value={squares[4]} onClick={() => handleClick(4)} />
<Box value={squares[5]} onClick={() => handleClick(5)} />
</div>
<div className="board-row">
<Box value={squares[6]} onClick={() => handleClick(6)} />
<Box value={squares[7]} onClick={() => handleClick(7)} />
<Box value={squares[8]} onClick={() => handleClick(8)} />
</div>
</>
);
};
const Box = ({
value,
onClick,
}: {
value?: boxValues;
onClick: () => void;
}) => {
return (
<button onClick={onClick} className="square">
{value}
</button>
);
};
const Status = ({
gameStatus,
user,
}: {
gameStatus: "ing" | "end";
user: string;
}) => {
return (
<div className="status">
{(gameSituation[gameStatus] ? "nextUser is " : "winner is ") + user}
</div>
);
};
const History = ({
historyList,
onClick,
}: {
historyList?: Array<Array<boxValues>>;
onClick: (idx: number) => void;
}) => {
return (
<ol>
{historyList?.map((history, idx) => (
<li key={idx}>
<button onClick={() => onClick(idx)}>
{idx !== 0 ? "Go to move #" + idx : "Go to gameStart "}
</button>
</li>
))}
</ol>
);
};
일단 해당 state를 모두 넣고 한번 수정해보겠습니다.
현재 상대를 나타내는 값은 꼭 state여야 할까요? 경기 결과를 나타내는 값은 state여야 할까요? 어떤 값이 state여야 할까요?
히스토리에 들어가는 배열값은 state여야 합니다. 게임이 진행되면 값이 변하고, 부모 컴포넌트 레벨에 존재해야하고, 기존 state를 통해 계산되어질 수 없습니다.
그리고 순서만 알게 된다면, 모든 결과값을 나타낼 수 있게 됩니다.
3번과 4번의 내용에 조금 겹치는 부분이 있어 Step4에서 두 부분을 같이 수정해보겠습니다.
앱에서 최소한으로 필요한 state 데이터를 식별한 후에는, 이 state를 변경하는 데 책임이 있는 컴포넌트, 즉,state를 ‘소유’하는 컴포넌트를 식별해야 합니다. 기억하세요: React는 컴포넌트 계층 구조를 따라 부모 컴포넌트에서 자식 컴포넌트로, 아래로 내려가는 단방향 데이터 흐름을 따릅니다. 당장은 어떤 컴포넌트가 state를 가져야 하는지가 명확하지 않을 수도 있습니다. 이 개념을 처음 접하는 경우 어려울 수 있지만, 다음 과정을 따라가면 이해할 수 있을 거에요!
const [count setCount] = useState(0)
const [squares, setSquares] = useState<Array<boxValues>>(new Array(9).fill(null));
const [history, setHistory] = useState<Array<Array<boxValues>>>([
Array(9).fill(null),
]);
기존 state에서 history는 squares를 배열에 담은 값과 비슷하게 생김을 볼 수 있습니다.
history는 [squares]로 나타낸다면 history의 index를 나타내는 state만 있다면, squares를 나타내는 state는 사라져도 괜찮습니다. 그리고 history의 index는 순서를 나타내는 count와 같으므로, historyIdx라는 값을 하나만 둬서 squares와 squares state를 없애도록 합니다.
const [user,setUser] = useState("X");
const [gameStatus, setGameStatus] = useState<"ing" | "end">("ing");
여기서 user와 setUser는 둘 다 없어져도 괜찮습니다.
게임이 특정한 경우로 user의 차례가 달라지지 않고 count에 의존적입니다. count에 변화에 따라 user의 값이 달라지므로 user는 state를 없애도록 합니다.
gameStatus도 마찬가지 입니다. squares의 배열값에 의존적입니다. squares는 history에 의존적입니다. 즉 history[idx]의 값을 가지고 현재 게임의 상태를 나타낼 수 있으므로 없앨 수 있습니다.
Board에 있는 state입니다.
const [gameStatus, setGameStatus] = useState("ing");
///
사실 이건 step4의 state위치를 찾기 위해 조금 억지스럽게 들어가 있는 면이 있습니다. 사실 board에서 [value,setvalue]로 나타내고 해당 값을 game컴포넌트로 올리는 예제가 좀 더 괜찮다고 생각합니다.
///
이 상황에서 useState를 사용할 필요없이 board에서 관리된 state를 받아서 사용하도록 하면 없앨 수 있습니다.
그렇게 됐을때 남는 state의 전부입니다.
const [history, setHistory] = useState<Array<Array<boxValues>>>([
Array(9).fill(null),
]);
const [historyIdx, setHistoryIdx] = useState(0);
const Game = () => {
/**
* 필요한 state history, setHistory (square를 담을 state)
*/
const [history, setHistory] = useState<Array<Array<boxValues>>>([
Array(9).fill(null),
]);
/**
* 현재 historyIdx를 나타내는
*/
const [historyIdx, setHistoryIdx] = useState(0);
const currentSquares = history[historyIdx];
const handlePlay = (nextSquares: Array<boxValues>) => {
const nextHistory = [...history.slice(0, historyIdx + 1), nextSquares];
setHistory(nextHistory);
setHistoryIdx(nextHistory.length - 1);
};
const gameStatus = calculateWinner(history[historyIdx]);
const user =
(historyIdx + (gameStatus === "ing" ? 0 : 1)) % 2 === 0 ? "X" : "O";
return (
<>
<div className="game">
<div className="game-board">
{/* Status와 Board가 같은 gameStatus와 showUser를 공유함 */}
<Status gameStatus={gameStatus} user={user} />
<Board
gameStatus={gameStatus}
squares={currentSquares}
onPlay={handlePlay}
user={user}
/>
</div>
<History historyList={history} onClick={setHistoryIdx} />
</div>
</>
);
};
const Board = ({
squares,
onPlay,
gameStatus,
user,
}: {
squares: Array<boxValues>;
onPlay: (squares: Array<boxValues>) => void;
gameStatus: "ing" | "end";
user: "O" | "X";
}) => {
const handleClick = (idx: number) => {
if (gameStatus === "end" || squares[idx]) return;
const newSquares = [...squares];
newSquares[idx] = user;
onPlay(newSquares);
};
return (
<>
{squares
.filter((_, i) => i % 3 === 0)
.map((_, _idx: number) => {
const idx = _idx * 3;
return (
<div key={idx} className="board-row">
<Box
key={idx}
value={squares[idx]}
onClick={() => handleClick(idx)}
/>
<Box
key={idx + 1}
value={squares[idx + 1]}
onClick={() => handleClick(idx + 1)}
/>
<Box
key={idx + 2}
value={squares[idx + 2]}
onClick={() => handleClick(idx + 2)}
/>
</div>
);
})}
</>
);
};
const Box = ({
value,
onClick,
}: {
value?: boxValues;
onClick: () => void;
}) => {
return (
<button onClick={onClick} className="square">
{value}
</button>
);
};
const Status = ({
gameStatus,
user,
}: {
gameStatus: keyof typeof gameSituation;
user: "O" | "X";
}) => {
return (
<div className="status">
{(gameSituation[gameStatus] ? "nextUser is " : "winner is ") + user}
</div>
);
};
const History = ({
historyList,
onClick,
}: {
historyList?: Array<Array<boxValues>>;
onClick: Dispatch<SetStateAction<number>>;
}) => {
return (
<ol>
{historyList?.map((history, idx) => (
<li key={idx}>
<button onClick={() => onClick(idx)}>
{idx !== 0 ? "Go to move #" + idx : "Go to gameStart "}
</button>
</li>
))}
</ol>
);
};
역방향 데이터 흐름은 기존 코드를 작성하면서 넣어두었습니다.
코드가 흐르는 방향은 아래로 흐르지만, 실제 실행하는 위치는 하위 컴포넌트가 된 경우들입니다.
사실 실질적으로 데이터 흐름이 아래에서 위인 것 같지만, 아래 함수를 실행 할 수 있도록, 함수를 만들어서 props로 넘기기 때문에 함수를 데이터 정방향으로 흘러주었고, 실행만 한 것이라고 생각합니다.
실제 코드에서 해당 부분은
const handlePlay = (nextSquares: Array<boxValues>) => {
const nextHistory = [...history.slice(0, historyIdx + 1), nextSquares];
setHistory(nextHistory);
setHistoryIdx(nextHistory.length - 1);
};
<Board
gameStatus={gameStatus}
squares={currentSquares}
onPlay={handlePlay}
user={user}
/>
const Board = ({
squares,
onPlay,
gameStatus,
user,
}: {
squares: Array<boxValues>;
onPlay: (squares: Array<boxValues>) => void;
gameStatus: "ing" | "end";
user: "O" | "X";
}) => {
const handleClick = (idx: number) => {
if (gameStatus === "end" || squares[idx]) return;
const newSquares = [...squares];
newSquares[idx] = user;
onPlay(newSquares);
};
return (
<>
{squares
.filter((_, i) => i % 3 === 0)
.map((_, _idx: number) => {
const idx = _idx * 3;
return (
<div key={idx} className="board-row">
<Box
key={idx}
value={squares[idx]}
onClick={() => handleClick(idx)}
/>
<Box
key={idx + 1}
value={squares[idx + 1]}
onClick={() => handleClick(idx + 1)}
/>
<Box
key={idx + 2}
value={squares[idx + 2]}
onClick={() => handleClick(idx + 2)}
/>
</div>
);
})}
</>
);
};
Board컴포넌트에 함수를 내려주고, 데이터 변화가 가능하도록 만들어주고 있습니다.