블로그 목록
프론트엔드

Zustand로 전역 상태 간결하게 관리하기 — Redux 없이도 된다

React 프로젝트에서 전역 상태가 필요해지면 보통 Redux를 떠올린다. 근데 실제로 써보면 action, reducer, store 설정에 파일이 몇 개씩 생기고, 간단한 데이터 하나 공유하는 데 코드가 너무 많아진다. Context API로 해결하려고 하면 값이 바뀔 때마다 구독 중인 컴포넌트가 전부 리렌더링되는 문제가 생긴다.

Zustand는 이 중간 어딘가에 있다. Provider도 없고, reducer도 없다. create 함수 하나로 스토어를 만들고, 컴포넌트에서 훅처럼 꺼내 쓰면 끝이다.

설치

npm install zustand

스토어 만들기

import { create } from 'zustand'

type CartItem = {
  id: string
  name: string
  price: number
  quantity: number
}

type CartStore = {
  items: CartItem[]
  addItem: (item: CartItem) => void
  removeItem: (id: string) => void
  clearCart: () => void
  total: () => number
}

export const useCartStore = create<CartStore>((set, get) => ({
  items: [],

  addItem: (item) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === item.id)
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
        }
      }
      return { items: [...state.items, item] }
    }),

  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((i) => i.id !== id) })),

  clearCart: () => set({ items: [] }),

  total: () =>
    get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}))

set은 상태를 업데이트하고, get은 현재 상태를 읽는다. 파생값(total)을 함수로 만들어두면 계산된 값을 따로 관리하지 않아도 된다.

컴포넌트에서 사용

import { useCartStore } from '@/store/cart'

export function CartButton() {
  const items = useCartStore((state) => state.items)
  const addItem = useCartStore((state) => state.addItem)

  return (
    <button
      onClick={() =>
        addItem({ id: '1', name: '상품', price: 9900, quantity: 1 })
      }
    >
      장바구니 ({items.length})
    </button>
  )
}

선택자 함수를 넘기면 해당 슬라이스만 구독한다. items가 바뀌면 이 컴포넌트만 리렌더링되고, 다른 상태가 바뀌어도 영향 없다. Context의 전파 문제를 피할 수 있는 이유가 여기 있다.

새로고침에도 유지되게 — persist 미들웨어

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item) =>
        set((state) => {
          const existing = state.items.find((i) => i.id === item.id)
          if (existing) {
            return {
              items: state.items.map((i) =>
                i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
              ),
            }
          }
          return { items: [...state.items, item] }
        }),
      removeItem: (id) =>
        set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
      clearCart: () => set({ items: [] }),
      total: () =>
        get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    }),
    {
      name: 'cart-storage',
    }
  )
)

persist 미들웨어를 감싸면 상태가 자동으로 localStorage에 저장된다. 새로고침해도 장바구니가 유지된다. sessionStorage로 바꾸고 싶으면 storage: createJSONStorage(() => sessionStorage) 옵션만 추가하면 된다.

여러 스토어 나눠 관리하기

Zustand는 단일 글로벌 스토어를 강제하지 않는다. 도메인별로 스토어를 나누는 게 오히려 자연스럽다.

// store/auth.ts
export const useAuthStore = create<AuthStore>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
  logout: () => set({ user: null }),
}))

// store/cart.ts
export const useCartStore = create<CartStore>()(persist(...))

컴포넌트에서 필요한 스토어만 가져다 쓰면 된다. 연결이 필요하면 한 스토어 안에서 getState()로 다른 스토어를 참조할 수도 있다.

Redux와 비교

Zustand는 설정이 거의 없다는 게 장점이지만, 큰 팀에서 엄격한 단방향 데이터 흐름이 필요하거나 Redux DevTools 생태계가 중요하다면 Redux Toolkit이 더 맞을 수 있다. 물론 Zustand도 DevTools를 지원하긴 한다.

import { devtools } from 'zustand/middleware'

const useStore = create<Store>()(devtools((set) => ({ ... })))

전역 상태가 몇 가지 안 되는 프로젝트라면 Zustand가 체감상 훨씬 빠르다. 파일 하나, create 함수 하나로 시작할 수 있고, 필요하면 persist나 devtools를 미들웨어로 얹으면 된다.