[React 심화] Zustand란?

조아영·2025년 3월 24일

📌

Zustand는 상태관리 본연의 기능에 집중한 라이브러리.
복잡성을 줄이고, 간단하고 직관적인 상태관리 기능 제공. 단순화된 Flux 패턴 기반의 small, fast, scalable 상태관리 솔루션이며 Hooks 기반의 간편한 API 제공.

등장배경

상태관리의 중요성

기존 상태관리 라이브러리인 Redux는 강력한 기능과 다양한 미들웨어 지원하지만, 설정과 사용 방식이 복잡한 편.
상태관리는 모든 React 애플리케이션에서 핵심 요소.
작은 규모에서는 상태관리가 간단하지만, 애플리케이션이 커질수록 상태관리는 더 복잡해짐.

→ Zustand는 복잡성을 줄이고, 간단하고 직관적인 상태관리 기능 제공.

주요특징

  • 간결함 : 간단한 API 제공하기 때문에, 학습 곡선이 완만. 적은 설정 코드로 상태관리 구현 가능.
  • 성능 : 불필요한 리렌더링을 방지하는 등 성능최적화가 잘 되어 있음. 상태 변경 시 해당 상태를 구독(subscribe)한 컴포넌트만 리렌더링.
  • React와의 통합 : React의 훅(Hook)과 자연스럽게 통합 가능. 상태를 정의하고 이를 React 컴포넌트에서 쉽게 사용할 수 있어, 기존 React 개발 경험을 그대로 활용할 수 있음.

구독(subscribe)이란?
구독은 상태 변경을 감지하고, 해당 변경에 반응하는 컴포넌트만 업데이트하는 메커니즘 의미.
상태 일부가 변경되더라도, 그 값을 사용하지 않는 컴포넌트는 리렌더링되지 않음. 애플리케이션 전체 리렌더링 방지. 이를 통해 성능을 최적화하고 리렌더링으로 인한 성능 저하를 방지할 수 있음.

설치 및 기본 사용법

설치

npm install zustand

기본 사용법

// src/zustand/bearsStore.js

import { create } from "zustand";

const useBearsStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}));

export default useBearsStore;
// src/App.jsx

import "./App.css";
import useBearsStore from "./zustand/bearsStore";

function App() {
  **const bears = useBearsStore((state) => state.bears);
  const increasePopulation = useBearsStore((state) => state.increasePopulation);**
  return (
    <div>
      <h1>{bears} around here ...</h1>
      <button onClick={increasePopulation}>one up</button>
    </div>
  );
}
export default App;

Zustand vs Redux Toolkit

  • 설정과 사용법: Zustand는 간단한 설정과 직관적인 사용 방식 제공. Redux Toolkit은 더 구조화된 방법을 제공.
  • 성능: 두 라이브러리 모두 성능 최적화가 잘 되어 있지만, Zustand는 불필요한 리렌더링을 방지하는데 더 초점
  • 유연성: Zustand는 필요한 부분만 선택적으로 사용 가능. Redux Toolkit은 보다 강력한 구조화된 방법을 제공.
  • 커뮤니티와 자료: Zustand는 빠르게 성장 중이며 증가 추세. Redux Toolkit은 대형 커뮤니티와 풍부한 레퍼런스 보유.

Zustand

보일러플레이트 최소화. 상태 정의와 사용 과정 직관적.

import { create } from "zustand";

const useStore = create(set => ({
  bears: 0,
  increasePopulation: () => set(state => ({ bears: state.bears + 1 }))
}))

Redux Toolkit

보일러플레이트 많음. 비교적 많은 설정 코드 요구.

import { configureStore, createSlice } from '@reduxjs/toolkit'

const slice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: state => { state.value += 1 }
  }
})

const store = configureStore({ reducer: slice.reducer })

Zustand 장점과 단점

장점

  • 간편한 사용: 간단한 API와 직관적인 사용법 제공.
  • 성능 최적화: 불필요한 리렌더링을 방지.
  • React와의 완벽한 통합: React Hooks 기반 자연스러운 통합 지원.
  • 미들웨어 지원: persist, devtools 등.
  • 유연성: 필요한 기능만 선택 사용 가능.

단점

  • 규모가 커지면 관리 어려움: 상태가 많아지면 관리가 복잡해짐.

예시

기본 예제

상태 정의

// src/zustand/todosStore.js

import { create } from "zustand";

const useTodosStore = create(set => ({
  todos: [],
  addTodo: (todo) => set(state => ({ todos: [...state.todos, todo] })),
  removeTodo: (index) => set(state => ({
    todos: state.todos.filter((_, i) => i !== index)
  }))
}))

export default useTodosStore;

상태 사용

// src/App.jsx

import React, { useState } from "react";
import useTodosStore from "./zustand/todosStore";

function App() {
  const todos = useTodosStore((state) => state.todos);
  const addTodo = useTodosStore((state) => state.addTodo);
  const removeTodo = useTodosStore((state) => state.removeTodo);
  const [input, setInput] = useState("");

  return (
    <div>
      <h1>Todo List</h1>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button
        onClick={() => {
          addTodo(input);
          setInput("");
        }}
      >
        Add Todo
      </button>
      <ul>
        {todos.map((todo, index) => (
          <li key={index}>
            {todo} <button onClick={() => removeTodo(index)}>Remove</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

Zustand와 Immer의 결합

immer란?
Immer는 JavaScript에서 상태를 쉽게 변경할 수 있게 해주는 라이브러리. 원본 데이터를 변경하지 않고 마치 직접 수정하는 것처럼 코드를 작성할 수 있으며, Immer가 자동으로 불변성을 유지한 새 상태를 만들어줌.

직접 중첩된 상태를 업데이트 했을 때의 문제점

Zustand의 간편한 상태 업데이트 방식은 깊은 중첩 구조를 가지는 상태를 업데이트할 때 문제가 발생할 수 있음.
배열이나 객체의 중첩된 상태를 업데이트할 때 불변성을 유지하지 않으면 상태 반영이 제대로 이루어지지 않아, 예상치 못한 오류가 발생할 수 있음.

import create from "zustand";

// Zustand 스토어 생성
const useTodosStore = create((set) => ({
  todos: [],
  addTodo: (text) =>
    set((state) => {
      // 불변성을 어기는 예시: 직접 배열을 수정
      state.todos.push({ id: Date.now(), text, completed: false });
      return state;
    }),
  toggleTodo: (id) =>
    set((state) => {
      // 불변성을 어기는 예시: 직접 객체를 수정
      const todo = state.todos.find((todo) => todo.id === id);
      if (todo) {
        todo.completed = !todo.completed;
      }
      return state;
    }),
}));

export default useTodosStore;
// src/App.jsx

import useTodosStore from "./zustand/todosStore";

function App() {
  const { todos, addTodo, toggleTodo } = useTodosStore();

  return (
    <div>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <span
              style={{
                textDecoration: todo.completed ? "line-through" : "none",
              }}
              onClick={() => toggleTodo(todo.id)}
            >
              {todo.text}
            </span>
          </li>
        ))}
      </ul>
      <button
        onClick={() => addTodo(prompt("새로운 todolist를 입력해주세요."))}
      >
        Add Todo
      </button>
    </div>
  );
}

export default App;

리스트가 추가/수정 되더라도 다른 요소에 의해 리렌더링이 일어나지 않는 한 UI에 반영이 안됨.

immer 설치

npm install immer

todosStore.js 수정

import { create } from "zustand";
import { immer } from "zustand/middleware/immer";

const useTodosStore = create(
  immer((set) => ({
    todos: [
      {
        id: 1,
        title: "Learn Zustand",
        tasks: [{ id: 1, task: "Read documentation", done: false }],
      },
    ],
    addTask: (todoId, newTask) =>
      set((state) => {
        const todo = state.todos.find((todo) => todo.id === todoId);
        if (todo) {
          todo.tasks.push(newTask); // 불변성 유지: immer가 자동으로 처리
        }
        // return { todos: state.todos }; // 변경된 참조가 기존 상태와 같아 리렌더링되지 않음
      }),
    toggleTask: (todoId, taskId) =>
      set((state) => {
        const todo = state.todos.find((todo) => todo.id === todoId);
        if (todo) {
          const task = todo.tasks.find((task) => task.id === taskId);
          if (task) {
            task.done = !task.done; // 불변성 유지: immer가 자동으로 처리
          }
        }
        // return { todos: state.todos }; // 변경된 참조가 기존 상태와 같아 리렌더링되지 않음
      }),
  }))
);

export default useTodosStore;

기타 유용한 패턴

선택적 상태 구독

상태의 특정 부분만 구독하여 성능 최적화 할 수 있음. 리렌더링 범위 최소화 가능.

// src/App.jsx

const todos = useTodosStore((state) => state.todos);
const addTask = useTodosStore((state) => state.addTask);
const toggleTask = useTodosStore((state) => state.toggleTask);

미들웨어 사용

import { produce } from "immer";
import { create } from "zustand";
import { persist } from "zustand/middleware";

const useTodosStore = create(
  persist(
    (set) => ({
      todos: [
        {
          id: 1,
          title: "Learn Zustand",
          tasks: [{ id: 1, task: "Read documentation", done: false }],
        },
      ],
      addTask: (todoId, newTask) =>
        set(
          produce((state) => {
            const todo = state.todos.find((todo) => todo.id === todoId);
            if (todo) {
              todo.tasks.push(newTask); // 불변성 깨짐: 직접 수정
            }
            // return { todos: state.todos }; // 변경된 참조가 기존 상태와 같아 리렌더링되지 않음
          })
        ),
      toggleTask: (todoId, taskId) =>
        set(
          produce((state) => {
            const todo = state.todos.find((todo) => todo.id === todoId);
            if (todo) {
              const task = todo.tasks.find((task) => task.id === taskId);
              if (task) {
                task.done = !task.done; // 불변성 깨짐: 직접 수정
              }
            }
            // return { todos: state.todos }; // 변경된 참조가 기존 상태와 같아 리렌더링되지 않음
          })
        ),
    }),
    {
      name: "todos-storage", // 저장소 이름 설정.
      // getStorage: () => sessionStorage, // localStorage가 아닌 곳에 저정하고 싶다면.
    }
  )
);

export default useTodosStore;

persist를 이용해서 새로고침을 하더라도 데이터 유지. persist는 zustand 내장이므로 별도 설치할 필요가 없음.
위와 같이 하면 새로고침 이후에도 localStorage에서 데이터를 관리하기 때문에 지속성을 유지할 수 있음.

0개의 댓글