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를 미들웨어로 얹으면 된다.