1. Zustand를 왜 쓰는가?
Zustand는 이런 상황에서 유용:
- 컴포넌트 간 상태 공유가 필요할 때 (예: 로그인 상태, 장바구니)
- Redux처럼 복잡한 설정 없이 바로 쓰고 싶을 때
- 상태가 많아지고 컴포넌트에 prop drilling이 많아졌을 때
2. 기본 개념
Zustand는 다음 3가지를 중심:
| store | 전역 상태 저장소 |
| setState | 상태를 변경하는 함수 |
| getState | 상태를 조회하는 함수 (일반적으로 내부에서만 사용) |
Zustand는 store를 만들고, 그 상태를 사용하는 것.
3. 기본 사용법
// store.ts
import { create } from 'zustand'
type BearState = {
bears: number
increase: () => void
}
export const useBearStore = create<BearState>((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
}))
// 컴포넌트.tsx
import { useBearStore } from './store'
function BearCounter() {
const bears = useBearStore((state) => state.bears)
const increase = useBearStore((state) => state.increase)
return (
<>
<h1>{bears}마리의 곰</h1>
<button onClick={increase}>곰 추가</button>
</>
)
}
4. 주요 특징
| 매우 간단한 API | Redux보다 훨씬 적은 코드로 상태 관리 가능 |
| selector 지원 | useStore((state) => state.xxx) 형태로 필요한 상태만 구독 |
| React Context X | 내부적으로 context를 쓰지 않고도 전역 상태 공유 |
| Immer, devtools, persist 플러그인 | 불변성 관리, Redux Devtools 연결, 로컬 스토리지 저장까지 지원 |
Zustand 구독(Subscription) 개념
Zustand에서 컴포넌트는 useStore((state) => state.속성) 형태로 원하는 상태만 구독합니다.
- 구독하는 상태가 바뀔 때만 해당 컴포넌트가 리렌더링
- 불필요한 리렌더링을 방지하면서도, 상태 변화 감지를 정밀하게 할 수 있음
선택적 구독
import { create } from 'zustand';
interface CounterStore {
count: number;
inc: () => void;
dec: () => void;
}
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
dec: () => set((state) => ({ count: state.count - 1 })),
}));
상태 구독 예시
function CounterDisplay() {
const count = useCounterStore((state) => state.count); // count만 구독
return <div>Count: {count}</div>;
}
function IncrementButton() {
const inc = useCounterStore((state) => state.inc); // inc만 구독
return <button onClick={inc}>+</button>;
}
CounterDisplay는 count만 바뀔 때 리렌더링되고,
IncrementButton은 리렌더링되지 않음 (함수는 변하지 않기 때문)
React Context는 전체 상태 변경 시 모든 하위 소비자가 다시 렌더링됩니다. Zustand는 이를 정확히 필요한 속성만 구독하여 해결합니다.
구독 구조 내부 동작
useStore(selector) // 내부적으로 shallow 비교 (===)
- Zustand는 내부적으로 subscribe()로 selector 함수의 반환값을 비교
- 값이 변하지 않으면 리렌더링하지 않음
- shallow import해서 배열/객체의 얕은 비교도 지원 가능
import { shallow } from 'zustand/shallow';
const { x, y } = useStore(state => ({ x: state.x, y: state.y }), shallow);
고급: 수동 구독 (store.subscribe)
Zustand는 React 없이도 사용할 수 있도록 명시적인 subscribe 메서드도 제공합니다:
const unsubscribe = useCounterStore.subscribe(
(newCount) => {
console.log('Count changed:', newCount);
},
(state) => state.count // selector
);
- subscribe(listener, selector)
- 유틸리티나 외부 로직(예: 로깅, 애니메이션 트리거 등)에 유용
이건 주로 onChange 감지처럼 UI 외적인 용도에 활용합니다.
1shallow – 얕은 비교 최적화
Zustand는 selector를 사용해 특정 상태만 구독하게 할 수 있습니다. 하지만, 객체나 배열을 리턴하면 참조 비교(===)로 인해 항상 리렌더링될 수 있습니다.
이때 shallow를 사용하면 얕은 값 비교만으로 리렌더링을 방지할 수 있습니다.
리렌더링 줄이기
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
interface Store {
x: number;
y: number;
setX: (v: number) => void;
setY: (v: number) => void;
}
export const useMyStore = create<Store>((set) => ({
x: 1,
y: 2,
setX: (v) => set({ x: v }),
setY: (v) => set({ y: v }),
}));
// ❌ shallow 없이 객체 selector 사용 → 항상 리렌더링
const { x, y } = useMyStore((s) => ({ x: s.x, y: s.y }));
// ✅ shallow 사용 시 x 또는 y가 바뀌지 않으면 리렌더링 안 됨
const { x, y } = useMyStore((s) => ({ x: s.x, y: s.y }), shallow);
shallow는 언제 써야 할까?
- 객체/배열을 리턴하는 selector를 사용할 때
- 여러 속성을 묶어 구독할 때
- 렌더 최적화를 신경 써야 할 컴포넌트에서만 쓰는 게 좋음
자주 쓰는 기능 확장
Zustand의 persist 미들웨어와 그 옵션들
zustand/middleware에 들어있는 기능 중 하나로, 상태를 브라우저 저장소(localStorage 또는 sessionStorage)에 저장하고 앱이 다시 실행됐을 때도 복구
1. persist 기본 구조
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useStore = create(
persist(
(set, get) => ({
// 상태 정의
}),
{
name: 'store-key', // 필수: 저장소에 저장될 key
// 옵션들...
}
)
)
첫 번째 인자: 상태 정의 함수
두 번째 인자: 옵션 객체
2. 자주 쓰는 옵션들 정리
name: string (필수)
- 브라우저 저장소에 저장될 key 이름.
- localStorage.getItem(name)으로 확인 가능.
name: 'user-store'
storage?: StateStorage
- 어떤 저장소를 쓸지 지정 (localStorage or sessionStorage)
- 기본값은 localStorage
storage: createJSONStorage(() => sessionStorage)
createJSONStorage는 내부적으로 setItem / getItem 등을 자동으로 JSON stringify/parse
partialize?: (state) => Partial<State>
- 상태 중 일부만 저장하고 싶을 때 사용 (예: token만 저장하고 싶을 때)
partialize: (state) => ({ token: state.token })
version?: number
- 상태 구조가 바뀌었을 때 버전을 지정해서 마이그레이션에 활용.
- 예를 들어 v1에선 name만 있었고, v2에선 age가 추가되었다면:
version: 2
migrate?: (persistedState, version) => newState
- 저장된 버전과 현재 버전이 다를 때 상태를 마이그레이션할 수 있는 함수
migrate: (persistedState, version) => {
if (version === 1) {
return {
...persistedState,
age: 0, // 새 필드 추가
}
}
return persistedState
}
onRehydrateStorage?: (state) => (state?, error?) => void
- 저장된 상태를 복원할 때 실행할 훅
- 예를 들어 상태 복구 중 로딩 스피너를 보여주거나, 복구 성공 여부를 로깅
onRehydrateStorage: () => {
console.log('hydrating...')
return (state, error) => {
if (error) console.error('hydration error', error)
else console.log('hydration success', state)
}
}
3. 전체 예제
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
type AuthState = {
token: string | null
setToken: (token: string) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
setToken: (token) => set({ token }),
logout: () => set({ token: null }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ token: state.token }),
version: 1,
onRehydrateStorage: () => (state, err) => {
if (err) console.error('Rehydrate error:', err)
else console.log('Rehydrated:', state)
},
}
)
)
4. 언제 쓰면 좋을까?
- 로그인 유지: JWT 토큰을 로컬에 저장해서 새로고침해도 로그인 유지
- 장바구니, 임시 설정값: 사용자의 선택을 페이지 리로드 이후에도 복원
- 다크모드 설정, 폼 임시 저장 등에도 유용
immer – 불변성 편하게 유지하기
Zustand는 원래 set 함수에서 직접 새로운 객체를 리턴해야 합니다.
하지만 immer를 쓰면, 직접 변경하는 것처럼 코드를 작성하되, 내부적으로는 불변성이 유지됩니다.
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface State {
count: number;
todos: string[];
addTodo: (todo: string) => void;
}
export const useTodoStore = create(
immer<State>((set) => ({
count: 0,
todos: [],
addTodo: (todo) =>
set((state) => {
state.todos.push(todo); // ✅ 직접 변경처럼 보이지만 immer가 처리
state.count += 1;
}),
}))
);
immer를 쓰면 set((state) => { ... }) 안에서 직접 state를 mutate해도 자동으로 immutable update가 됩니다.
immer는 언제 쓰는 게 좋은가?
- 배열 push/pop/splice 등 복잡한 구조 변경이 많을 때
- state.form.xxx 같이 중첩된 구조가 깊을 때
- set({ ...prev, ... }) 코드가 지저분하고 반복될 때
Zustand의 devtools 미들웨어
Redux DevTools와 연동돼서 브라우저에서 상태 변화 추적.
디버깅, 타임트래블, 상태 롤백 등 개발 중 상태 확인이 필요한 상황에 매우 유용
1. 사전 준비
Zustand의 devtools 미들웨어는 Redux DevTools 확장 프로그램이 있어야 작동.
https://chromewebstore.google.com/category/extensions
Chrome Web Store
브라우저에 새로운 기능을 추가하고 탐색 환경을 맞춤설정합니다.
chromewebstore.google.com
에서 Redux devtool 설치
설치 후, 개발자 도구의 Redux 탭에서 상태 확인 가능.
2. 기본 사용법
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
type CounterState = {
count: number
increase: () => void
}
export const useCounterStore = create<CounterState>()(
devtools(
(set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
}),
{ name: 'CounterStore' }
)
)
옵션 설명
- name: DevTools에 표시될 이름
- enabled: DevTools 연결 여부 (기본은 true, prod에서는 false로 끄는 게 일반적)
3. 조건부로 DevTools 연결 (실전 예제)
보통은 개발 모드에서만 devtools 사용
import { devtools } from 'zustand/middleware'
const useStore = create(
devtools(
(set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}),
{
name: 'MyStore',
enabled: import.meta.env.MODE === 'development', // Vite 기준
}
)
)
Next.js나 CRA라면 process.env.NODE_ENV === 'development' 사용
4. Redux DevTools로 볼 수 있는 기능들
- 액션 추적 (increase, reset 등 이름 지정 가능)
- 상태 변경 로그
- 상태 롤백, 타임트래블
- set, get 기반으로 디버깅 쉽게 가능
5. 액션 이름 커스터마이징
set을 쓸 때 액션 이름을 지정하면 DevTools에서 보기 쉬워짐:
increase: () =>
set((state) => ({ count: state.count + 1 }), false, 'increase count'),
set(partialState, replace?, actionName?)
6. persist와 함께 쓰기
Zustand는 미들웨어 체이닝이 가능. persist + devtools를 함께 쓰고 싶다면 이렇게:
import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'
const useStore = create(
devtools(
persist(
(set) => ({
token: '',
setToken: (token) => set({ token }),
}),
{
name: 'auth-storage',
}
),
{ name: 'AuthStore' }
)
)
순서 중요: devtools(persist(...)) 형태로 감싸야 Devtools에서도 상태 확인 가능.
zukeeper는 Zustand 상태를 DevTools 없이도 개발 중 더 쉽게 추적하고 디버깅할 수 있도록 도와주는 툴
공식 명칭은 zukeeper인데, 이름처럼 Zustand를 "관리자처럼" 들여다볼 수 있게 해주는 개발 툴
1. Zukeeper란?
Zukeeper는 브라우저 확장 프로그램 + 미들웨어 조합
- zustand 상태를 시각적으로 추적하고 검사할 수 있음
- Redux DevTools 없이도 상태 확인 가능
- 여러 상태 store를 탭별로 구분, 시각적으로 구성
- zukeeper는 Zustand 전용 DevTool
- 상태를 브라우저에서 GUI로 실시간 추적 가능
- withZukeeper()로 간단히 연동
- persist, devtools 등과 함께 사용 가능
- 개발 중 상태 관리와 디버깅을 시각적으로 도와줌
2. 설치 방법
브라우저 확장 설치
Chrome 웹 스토어에서 Zukeeper 검색 후 설치
패키지 설치 (npm)
npm install zukeeper
3. 기본 사용법
Zukeeper를 Zustand에 연결:
기존 create 호출 시 미들웨어로 연결
import { create } from 'zustand'
import { withZukeeper } from 'zukeeper'
type Store = {
count: number
increase: () => void
}
export const useCounterStore = create<Store>()(
withZukeeper((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
}))
)
withZukeeper(...) 함수로 감싸는 것만으로 Zukeeper DevTool과 연동.
4. 옵션 사용법
withZukeeper는 옵션 가능:
withZukeeper(
(set) => ({
...
}),
{
name: 'MyCustomStore',
}
)
| name | Zukeeper 탭에서 store 이름 지정 |
5. devtools, persist와 함께 쓰기
Zustand 미들웨어는 체이닝 가능, 다음처럼 사용 가능:
import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'
import { withZukeeper } from 'zukeeper'
type Store = {
token: string
setToken: (t: string) => void
}
export const useAuthStore = create<Store>()(
withZukeeper(
devtools(
persist(
(set) => ({
token: '',
setToken: (token) => set({ token }),
}),
{
name: 'auth-storage',
}
),
{ name: 'Auth Devtool' }
),
{ name: 'Auth Store (Zukeeper)' }
)
)
6. 실제 사용 모습
확장 프로그램 설치 후 페이지에서 상태가 업데이트될 때, Chrome DevTools의 Zukeeper 탭에서 다음을 확인 가능:
- 상태 필드별 현재 값
- 업데이트 로그
- 각 store 간 구분
- 상태 변경 시점 비교
7. Zukeeper vs Redux DevTools vs Console.log
| Zukeeper | Zustand 전용, 시각적 디버깅, 간편 연동 |
| Redux DevTools | Redux 기반 툴, 타임트래블, 롤백 기능 강력 |
| console.log | 단순한 디버깅용, 반복 제거 어려움 |
Zukeeper는 특히 Redux DevTools 없이 상태를 가볍게 확인하고 싶은 경우 추천.
zustand/vanilla를 사용하는 상황과 이유
| React 외부에서 상태 제어 | Web Worker, service, 유틸 함수 등 |
| React 이외 프레임워크 | 예: Vue, Svelte, React Native 외부 모듈 등 |
| 프레임워크에 구애받지 않는 중앙 상태 스토어 설계 | 독립된 도메인 상태 관리 가능 |
| subscribe() 위주 활용 | 직접 상태를 감시하고 작업 수행할 때 유용 |
1. Vanilla Zustand 스토어 만들기
// store.ts
import { createStore } from 'zustand/vanilla';
type AppState = {
count: number;
inc: () => void;
};
export const counterStore = createStore<AppState>((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
}));
이 스토어는 React를 전혀 사용하지 않고도 상태를 관리할 수 있습니다.
2. React에서 이 vanilla store 사용하기
// App.tsx
import { useStore } from 'zustand';
import { counterStore } from './store';
export function Counter() {
const count = useStore(counterStore, (state) => state.count);
const inc = useStore(counterStore, (state) => state.inc);
return (
<div>
<div>Count: {count}</div>
<button onClick={inc}>+</button>
</div>
);
}
useStore(store, selector)로 zustand/vanilla 스토어를 React 컴포넌트와 연결할 수 있습니다.
3. React 외부에서 사용하기
// anywhere.ts
import { counterStore } from './store';
counterStore.getState().inc(); // 직접 호출
console.log(counterStore.getState().count); // 현재 값 가져오기
const unsubscribe = counterStore.subscribe((state) => {
console.log('Count changed:', state.count);
});
이처럼 React 외부에서도 상태 접근 및 구독이 가능하므로, 로직 분리나 테스트에 매우 유리합니다.
4. middleware도 가능 (immer, devtools 등)
import { createStore } from 'zustand/vanilla';
import { immer } from 'zustand/middleware/immer';
interface TodoState {
todos: string[];
add: (text: string) => void;
}
export const todoStore = createStore(
immer<TodoState>((set) => ({
todos: [],
add: (text) =>
set((state) => {
state.todos.push(text);
}),
}))
);
구조적 정리
// store.ts (순수 스토어)
export const myStore = createStore<MyType>((set) => {...});
// component.tsx (React 연결)
const value = useStore(myStore, (s) => s.value);
// logic.ts (비 UI 구독 및 조작)
myStore.subscribe(...);
myStore.getState().doSomething();
장점
| 프레임워크 독립 | React 없이도 상태 공유 가능 |
| 테스트 편의성 | pure JS 객체라 단위 테스트 쉬움 |
| 외부 통합 가능 | 소켓, 서비스 워커, 이벤트 리스너와 통합 용이 |
'JavaScript > React' 카테고리의 다른 글
| React Hook Form (1) | 2025.05.15 |
|---|---|
| React Query (1) | 2025.05.15 |
| React에서 화면 사이즈 감지하기(반응형 설정하기, window.innerWidth, matchMedia, react-responsive, tailwind) (0) | 2025.03.31 |
| .env 파일과 실행 모드 (1) | 2025.03.28 |
| Jest와 React Test Library(RTL) (0) | 2025.03.25 |