React

[Zustand] Zustand란? 공식문서와 함께 Zustand 시작하기(틱택토)

ride-dev 2024. 12. 7. 12:53

제목

[Zustand] Zustand란? 공식문서와 함께 Zustand 시작하기(틱택토)

관련게시글

[Zustand] Zustand란? 공식문서와 함께 Zustand 시작하기(틱택토)

목차

0. Zustand?

1. Zustand 시작하기

2. 틱택토(Tic-Tac-Toe) 만들기-vite

3. 참고자료

0. Zustand?

Zustand는 전역 상태 관리 도구 중 하나이며,

공식문서를 기반으로 Zustand 에 대해 더 자세히 알아보겠습니다.

A small, fast, and scalable bearbones state management solution.

zustand는 작고(small), 빠르며(fast), 확장 가능하고(scalable)

필수적인 기능만을 제공하는(barebones) 상태 관리 솔루션입니다.

zustand는  훅(hook) 기반의 API를 제공합니다.

보일러플레이트 코드나 강제성이 적지만,

명확하고,

flux와 유사한 구조를 가졌습니다.

0.1. React의 상태관리 useState

일반적인 상황에서 React가 상태를 관리할 때,

불변객체인 useState를 활용합니다.

App 컴포넌트에서 value state를 선언하고,

이를 다른 Component에 props로 내려줬다고 가정해보겠습니다.

하위 컴포넌트에서 value를 수정하면,

해당 state를 사용하는 다른 컴포넌트들에게

데이터가 변경되었음을 전달하고,

변경된 데이터를 기반으로 component를 Re-render 합니다.

설계에 따라 컴포넌트 구조가 깊어질 수 있으며,

해당 state를 사용하지 않고 props로 전달만 해도

중간 단계에 있는 컴포넌트가 Re-render됩니다.

 

0.1.2 전역 상태 관리 도구

전역 상태 관리 도구를 통해 필요한 부분만 Re-render 되도록 할 수 있습니다.

 

1. Zustand 시작하기

1.1 npm install

zustand는 NPM 으로 제공되기 때문에,

npm install 명령어를 사용하여 zustand를 사용할 수 있습니다.

npm install zustand

1.2 Store 생성하기

관리하고자 하는 상태를 store에  선언하고, set을 통해 상태를 병합(merge)합니다.

import { create } from 'zustand'

const useStore = create((set) => ({
  // 관리하고자하는 상태
  bears: 0,
  // 상태를 관리하는 함수들
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
  updateBears: (newBears) => set({ bears: newBears }),
}))

1.3 컴포넌트를 바인딩하여 사용하기

앞서 선언한 useStore를 어디서든지 사용할 수 있습니다.

useContext처럼 Provider를 선언하지 않아도 됩니다.

state(상태, 예제에서는 bears를 의미함)가 변경되면,

해당 state를 사용하는 컴포넌트가 Re-render 됩니다.

function BearCounter() {
  const bears = useStore((state) => state.bears)
  return <h1>{bears} around here...</h1>
}

function Controls() {
  const increasePopulation = useStore((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

2. 틱택토(Tic-Tac-Toe) 만들기 - vite

zustand 공식문서 튜토리얼인 틱택토 만들기를 해보겠습니다.

틱택토를 간단히 설명하면, 3x3 오목입니다.

틱택토 게임에 대한 이해를 돕기 위해 구글 틱택토 게임 사진을 첨부했습니다.

구글 틱택토

zustand 공식문서의 튜토리얼 코드를 기반으로 프로젝트를 구현하되,

일부 구조를 변경할 예정입니다.

 

코드를 개선해나가는 방식이 아닌,

이미 완성된 코드를 차례차례 설명하는 방식으로 진행하겠습니다.

 

아래는 제가 구현한 틱택토입니다.

2.0 프로젝트 구조

전반적인 구조는 아래와 같습니다.

zustand-demo/
└── src/
    ├── components/
    │   ├── Board.jsx
    │   ├── Game.jsx
    │   ├── History.jsx
    │   └── Square.jsx
    ├── store/
    │   └── useGameStore.js
    ├── styles/
    │   ├── Board.module.css
    │   ├── common.module.css
    │   ├── Game.module.css
    │   ├── History.module.css
    │   └── Square.module.css
    ├── utils/
    │   └── calculateUtils.js
    └── App.jsx

 

화면에 해당하는 컴포넌트를 components 디렉토리에 생성합니다.

컴포넌트의 css를 styles 디렉토리에 작성합니다.

store 디렉토리에서 전역 상태 관리를 진행합니다.

utils에서 승리 조건, 턴 진행 등을 계산합니다.

2.1 프로젝트 생성

npm 명령어를 통해 리액트 - vite 프로젝트를 생성하고,

npm create vite@latest zustand-demo -- --template react

프로젝트 내부로 이동한 뒤,

cd zustand-demo/

npm install zustand 명령어를 통해 zustand를 설치합니다.

npm install zustand

프로젝트가 올바르게 구축되었다면, npm run dev명령어를 통해 구동시켜봅니다.

npm run dev

vite는 기본적으로 5173 port에서 실행됩니다.

http://localhost:5173

App.jsx에 있는 내용을 제거합니다.

function App() {

  return (
    <>
      {/* 생성한 컴포넌트를 넣을 자리 */}
    </>
  )
}

export default App

기본적인 설정을 끝마쳤으니 본격적으로 틱택토를 만들어보겠습니다.

2.2 전역 상태 관리 useGameStore.js

틱택토에서 사용할 변수를 zustand로 관리하겠습니다.

src/store에 useGameStore.js 파일을 생성합니다.

사용할 변수는 history, currentMove 입니다.

import { create } from 'zustand'
import { combine } from 'zustand/middleware'

const useGameStore = create(
    combine(
        { history: [Array(9).fill(null)], currentMove: 0 }, (set) => {
            return {
                setHistory: (nextHistory) => {
                    set((state) => ({
                        history:
                            typeof nextHistory === 'function'
                                ? nextHistory(state.history)
                                : nextHistory,
                    }))
                },
                setCurrentMove: (nextCurrentMove) => {
                    set((state) => ({
                        currentMove:
                            typeof nextCurrentMove === 'function'
                                ? nextCurrentMove(state.currentMove)
                                : nextCurrentMove,
                    }))
                },
                restartGame: () => {
                    set({
                        history: [Array(9).fill(null)],
                        currentMove: 0,
                    })
                },
            }
        },
    ),
)

export default useGameStore

2.2.1. history

history: [Array(9).fill(null)]

틱택토는 3x3으로 총 9개의 칸이 존재하며,

좌측 상단부터 0 - 8 의 index를 갖도록 합니다.

틱택토는 최대 9차례로 진행하기 때문에 10개의 배열을 생성합니다.

또한, 과거에 진행했던 차례로 되돌아갈 수 있도록 history 변수를 이중배열로 선언합니다.

0번째 배열은 게임을 다시 시작할 수 있도록 모든 값이 null입니다.

// 3차례를 진행했을 때
history: [Array(9), Array(9), Array(9), Array(9)]

2.2.2. currentMove

currentMove: 0

현재 진행중인 차례가 몇 번째인지 저장합니다.

이를 기반으로 'O','X' 중 어느 플레이어가 다음 차례인지 계산하고,

history에서 차례를 저장&되돌릴 때 사용합니다.

2.2.3. set

전역변수로 선언한 state를 수정할(merge) 메서드입니다.

restartGame은  history와 currentMove를 초기화합니다.

                setHistory: (nextHistory) => {
                    set((state) => ({
                        history:
                            typeof nextHistory === 'function'
                                ? nextHistory(state.history)
                                : nextHistory,
                    }))
                },
                setCurrentMove: (nextCurrentMove) => {
                    set((state) => ({
                        currentMove:
                            typeof nextCurrentMove === 'function'
                                ? nextCurrentMove(state.currentMove)
                                : nextCurrentMove,
                    }))
                },
                restartGame: () => {
                    set({
                        history: [Array(9).fill(null)],
                        currentMove: 0,
                    })
                },

2.3. calculateUtils.js

calculateUtils.js는 승리 조건, 차례 계산, 승패 여부를 계산하는 유틸입니다.

(승리조건이 하드코딩되어 있기에 추후, 이를 보완할 수도 있겠습니다)

src/utils 디렉토리에 생성하도록 합니다.

export const 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] // 승리한 플레이어 ('X' 또는 'O') 반환
        }
    }
    return null; // 승자가 없으면 null 반환
}

export const calculateTurns = (squares) => {
    // !square = 빈칸
    return squares.filter((square) => !square).length
}

export const calculateStatus = (winner, turns, player) => {
    if (!winner && !turns) return 'Draw'
    if (winner) return `Winner ${winner}`
    return `Next player: ${player}`
}

2.4. components & styles

컴포넌트와 스타일을 함께 다루도록 하겠습니다.

기존에는 컴포넌트의 div태그에 inline 코드를 작성했으나,

줄 수가 길어짐에 따라 css 파일로 분리했습니다.

2.4.1. App.jsx, common.module.css

import Game from "./components/Game"

function App() {
  return (
    <>
      <Game/>
    </>
  )
}

export default App
/* 공통 스타일 */
.centered {
    display: flex;
    justify-content: center;
    align-items: center;
}

.container {
    text-align: center;
    font-family: 'monospace';
}

/* 버튼 공통 스타일 */
.button {
    cursor: pointer;
    outline: none;
    font-size: 1rem;
    font-weight: bold;
    padding: 0.5rem 1rem;
    color: white;
    background-color: #6ABAAC;
    border: none;
    border-radius: 10px;
}

.button:hover {
    background-color: #5DA397;
}

/* 공통 타이틀 스타일 */
.title {
    font-size: 1.5rem;
    font-weight: bold;
    margin-bottom: 1rem;
}

2.4.2.Game.jsx, Game.module.css

import useGameStore from "../store/useGameStore";
import Board from "./Board";
import History from "./History";
import styles from "../styles/Game.module.css";
import commonStyles from "../styles/common.module.css";
const Game = () => {
    const { // zustand로 관리하는 전역 변수
        history, setHistory,
        currentMove, setCurrentMove,
        restartGame,
    } = useGameStore();

    const xIsNext = currentMove % 2 === 0;
    const currentSquares = history[currentMove];

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

    const jumpTo = (nextMove) => {
        setCurrentMove(nextMove);
    };

    return (
        <div className={styles.gameContainer}>
            <h1 className={styles.gameTitle}>틱택토</h1>
            <div className={styles.gameContent}>
                <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
                <History history={history} jumpTo={jumpTo} />
            </div>
            <button className={commonStyles.button} onClick={restartGame}>
                Restart Game 
            </button>
        </div>
    );
};

export default Game;
.gameContainer {
    display: flex;
    flex-direction: column; /* 제목과 아래 콘텐츠를 세로로 배치 */
    align-items: center; /* 제목 중앙 정렬 */
    gap: 2rem; /* 제목과 콘텐츠 사이 간격 */
    font-family: 'monospace';
    margin-top: 2rem;
    background-color: #ffffff;
    padding: 1rem;
    border-radius: 10px;
    border: 1px solid #dbdbdb;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}

.gameTitle {
    font-size: 2rem;
    font-weight: bold;
    margin: 0;
    text-align: center;
}

.gameContent {
    display: flex; /* Board와 History를 가로로 배치 */
    justify-content: center; /* 가로 정렬 */
    align-items: flex-start; /* 세로 정렬 */
    gap: 2rem; /* Board와 History 간격 */
    width: 100%; /* 콘텐츠 전체 너비 */
}

2.4.3. History.jsx, History.module.css

import styles from "../styles/History.module.css";
import commonStyles from "../styles/common.module.css";

const History = ({ history, jumpTo }) => {
    return (
        <div className={styles.historyList}>
            <div className={commonStyles.title}> History </div>
            <ol>
                {history.map((_, historyIndex) => {
                    const description =
                        historyIndex > 0
                            ? `Go to move #${historyIndex}`
                            : "Go to game start";
                    return (
                        <li key={historyIndex}>
                            <button
                                className={styles.historyButton}
                                onClick={() => jumpTo(historyIndex)}
                            >
                                {description}
                            </button>
                        </li>
                    );
                })}
            </ol>
        </div>
    );
};

export default History;
.historyList {
    display: flex;
    flex-direction: column; /* 리스트를 세로로 정렬 */
    align-items: flex-start; /* 버튼을 왼쪽 정렬 */
    justify-content: flex-start;
    width: 200px; /* 적절한 너비 설정 */
    text-align: left; /* 텍스트 왼쪽 정렬 */
}
.historyList ol {
    list-style: none; /* 숫자 제거 */
    padding: 0; /* 기본 패딩 제거 */
    margin: 0; /* 기본 마진 제거 */
}
.historyButton {
    cursor: pointer;
    outline: none;
    background-color: #ffffff;
    border: 1px solid #ffffff;
    color: black;
    padding: 0.5rem;
    width: 100%; /* 버튼이 전체 너비를 차지하도록 설정 */
    text-align: center;
}

.historyButton:hover {
    background-color: #dbdbdb;
}

2.4.4. Board.jsx, Board.module.css

import Square from './Square'
import { calculateWinner, calculateTurns, calculateStatus } from '../utils/calculateUtils'
import styles from "../styles/Board.module.css";
import commonStyles from "../styles/common.module.css";

const Board = ({ xIsNext, squares, onPlay }) => {
    const winner = calculateWinner(squares);
    const turns = calculateTurns(squares);
    const player = xIsNext ? 'X' : 'O';
    const status = calculateStatus(winner, turns, player);

    const handleClick = (i) => {
        if (squares[i] || winner) return;
        const nextSquares = squares.slice();
        nextSquares[i] = player;
        onPlay(nextSquares);
    };
    return (
        <div className={`${commonStyles.centered}`}>
            <div className={styles.boardContainer}>
                <div className={commonStyles.title}>{status}</div>
                <div className={styles.boardGrid}>
                    {squares.map((square, squareIndex) => (
                        <Square
                            key={squareIndex}
                            value={square}
                            onSquareClick={() => handleClick(squareIndex)}
                        />
                    ))}
                </div>
            </div>
        </div>
    );
};

export default Board;
.boardContainer {
    text-align: center;
    width: 600px;
    background-color: #6ABAAC;
    padding: 1rem;
}

.boardGrid {
    width: 320px;
    height: 320px;
    display: grid;
    grid-template-columns: repeat(3, 100px);
    grid-template-rows: repeat(3, 100px);
    gap: 10px;
    margin: 0 auto;
    background-color: #5DA397;
}

 

2.4.5. Square.jsx, Square.module.css

import styles from "../styles/Square.module.css";
const Square = ({ value, onSquareClick }) => {
    return (
        <button
            className={`${styles.square} ${value ? styles[value] : ""}`}
            onClick={onSquareClick}
        >
            {value}
        </button>
    );
};

export default Square
.square {
    width: 100%;
    height: 100%;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: none;
    background-color: #6ABAAC;
    font-size: 2rem;
    font-weight: bold;
    cursor: pointer;
    outline: none;
}

.square.X {
    color: #545454;
}

.square.O {
    color: #EFEBD5;
}

3. 참고자료

자세한 내용은 공식문서에서 확인할 수 있습니다.

https://zustand.docs.pmnd.rs/getting-started/introduction

 

Introduction - Zustand

How to use Zustand

zustand.docs.pmnd.rs

https://ko.react.dev/learn

 

빠르게 시작하기 – React

The library for web and native user interfaces

ko.react.dev

 

728x90