Zustand는 이 순서로 익히면 됩니다
먼저 로컬 상태와 공유 상태의 경계를 정하고, TypeScript로 작은 store를 만든 뒤 action과 원자 selector를 익힙니다. 여러 값을 한 번에 선택할 때만 useShallow를 검토하고, 저장할 이유가 분명한 상태에만 마지막 단계로 persist를 붙입니다. Next.js에서는 요청 간 store 공유와 hydration까지 별도 문제로 다뤄야 합니다.
검증 기준: 2026년 7월 19일 확인한 Zustand 공식 문서와 Next.js 가이드입니다. 예제는 React·TypeScript를 기준으로 하며 설치한 Zustand 버전의 API를 함께 확인하세요.
- 0단계: 로컬·공유·서버 상태 경계
- 1단계: TypeScript store 만들기
- 2단계: action으로 변경 규칙 모으기
- 3단계: selector와 useShallow
- 4단계: persist·version·migrate
- 5단계: hydration 이해하기
- 6단계: Next.js 요청별 store
- 실무 점검표

0단계: 무엇을 store에 넣을지 먼저 정한다
Zustand 문법부터 외우면 입력값, hover, 서버 응답까지 전부 전역 store에 넣기 쉽습니다. 학습의 시작점은 create가 아니라 상태의 소유 범위입니다. 한 컴포넌트나 가까운 부모·자식 안에서만 쓰는 값은 useState가 더 단순할 수 있습니다.
| 상태 예시 | 첫 선택 | store를 검토할 신호 |
|---|---|---|
| 입력 중인 검색어, 단일 모달 | useState |
서로 먼 화면이 같은 값을 읽고 변경함 |
| 테마, 장바구니, 여러 영역의 필터 | 공유 위치에 따라 Context 또는 Zustand | Provider 계층과 변경 로직이 복잡해짐 |
| API 목록, 캐시, 재시도 상태 | 요구사항에 맞는 data fetching 계층 | Zustand로 직접 캐시를 설계할 명확한 이유가 있음 |
| URL로 공유해야 하는 필터 | route 또는 search params | URL과 store를 동기화할 책임을 감수할 수 있음 |
판단이 아직 어렵다면 Zustand란 무엇인가에서 props, Context, Zustand, 서버 상태의 선택 기준을 먼저 확인하세요.
1단계: TypeScript store를 작게 만든다
TypeScript에서는 공식 문서가 안내하는 커리드 형태 create<State>()(...)를 기본으로 두면 state와 action의 형태를 명시적으로 검사할 수 있습니다. 처음부터 모든 앱 상태를 하나로 합치지 말고 기능 경계가 분명한 작은 store로 시작하세요.
// stores/useProductFilterStore.ts
import { create } from 'zustand'
type Sort = 'recommended' | 'price-asc' | 'price-desc'
type ProductFilterStore = {
keyword: string
category: string
sort: Sort
setKeyword: (keyword: string) => void
setCategory: (category: string) => void
setSort: (sort: Sort) => void
reset: () => void
}
const initialState = {
keyword: '',
category: 'all',
sort: 'recommended' as Sort,
}
export const useProductFilterStore = create<ProductFilterStore>()((set) => ({
...initialState,
setKeyword: (keyword) => set({ keyword }),
setCategory: (category) => set({ category }),
setSort: (sort) => set({ sort }),
reset: () => set(initialState),
}))
Zustand의 set은 기본적으로 state 객체를 얕게 병합합니다. 중첩 객체를 바꿀 때는 내부 필드가 자동으로 깊게 합쳐진다고 생각하지 말고 새 객체를 명시적으로 만드세요. 전체 state를 교체하는 두 번째 인자 true는 action까지 지울 수 있으므로 필요한 경우에만 조심해서 사용합니다.
2단계: action으로 변경 규칙을 모은다
action은 Zustand가 강제하는 특별 문법이 아니라 store에 함께 둔 함수입니다. 그러나 컴포넌트마다 setState로 서로 다른 객체를 만들기 시작하면 같은 기능의 규칙이 흩어집니다. “카테고리를 바꾸면 페이지를 1로 초기화한다” 같은 도메인 규칙은 하나의 action으로 묶는 편이 추적하기 쉽습니다.
type CatalogStore = {
category: string
page: number
selectCategory: (category: string) => void
nextPage: () => void
}
export const useCatalogStore = create<CatalogStore>()((set) => ({
category: 'all',
page: 1,
selectCategory: (category) => set({ category, page: 1 }),
nextPage: () => set((state) => ({ page: state.page + 1 })),
}))
이전 값으로 다음 값을 계산할 때는 set((state) => ...) updater를 사용합니다. 컴포넌트는 구현 세부사항 대신 selectCategory, nextPage 같은 동작 이름을 호출합니다.
3단계: 필요한 값만 selector로 구독한다
useProductFilterStore()처럼 selector 없이 전체 store를 읽으면 그 컴포넌트는 store 전체를 구독합니다. 작은 예제에서는 편하지만 store가 커질수록 관련 없는 변경에도 다시 렌더링될 수 있습니다. 원자 selector를 기본으로 두면 컴포넌트의 의존성이 코드에 드러납니다.
export function SearchInput() {
const keyword = useProductFilterStore((state) => state.keyword)
const setKeyword = useProductFilterStore((state) => state.setKeyword)
return (
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
aria-label='상품 검색'
/>
)
}
여러 값을 객체나 배열 하나로 묶어 선택하면 selector가 실행될 때마다 새 참조가 생길 수 있습니다. 선택 결과의 최상위 값이 같을 때 렌더링을 건너뛰고 싶다면 useShallow를 적용합니다. 무조건 모든 selector에 붙이지 말고 여러 pick을 묶어 반환하는 경우에 검토하세요.
import { useShallow } from 'zustand/react/shallow'
export function FilterSummary() {
const { category, sort, reset } = useProductFilterStore(
useShallow((state) => ({
category: state.category,
sort: state.sort,
reset: state.reset,
})),
)
return (
<div>
<p>{category} · {sort}</p>
<button type='button' onClick={reset}>초기화</button>
</div>
)
}
리렌더링 원인을 더 자세히 확인하려면 Zustand 리렌더링과 selector에서 전체 구독, 새 객체 반환, 얕은 비교를 구분해 보세요.
4단계: persist는 저장할 상태에만 붙인다
persist는 새로고침이나 앱 재시작 뒤 상태를 복원하는 middleware입니다. store 구조를 자동으로 좋게 만드는 기능이 아닙니다. 테마와 목록 표시 방식처럼 다시 방문해도 유지할 값만 partialize로 골라 저장하고, 모달 열림·요청 진행·서버 응답 캐시·민감한 인증값은 관성적으로 저장하지 마세요.

// 클라이언트 전용 SPA 예제
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
type Theme = 'light' | 'dark'
type PreferencesStore = {
theme: Theme
compact: boolean
setTheme: (theme: Theme) => void
toggleCompact: () => void
}
type PersistedPreferences = Pick<PreferencesStore, 'theme' | 'compact'>
export const usePreferencesStore = create<PreferencesStore>()(
persist(
(set) => ({
theme: 'light',
compact: false,
setTheme: (theme) => set({ theme }),
toggleCompact: () => set((state) => ({ compact: !state.compact })),
}),
{
name: 'preferences',
storage: createJSONStorage(() => localStorage),
skipHydration: true,
partialize: (state) => ({ theme: state.theme, compact: state.compact }),
version: 1,
migrate: (persisted, version) => {
const previous = persisted as PersistedPreferences & { darkMode?: boolean }
if (version === 0) {
return {
theme: previous.darkMode ? 'dark' : 'light',
compact: previous.compact ?? false,
}
}
return persisted as PersistedPreferences
},
},
),
)
version은 저장 구조의 버전이고, 저장된 버전이 현재와 다를 때 migrate가 최신 구조를 반환합니다. 필드명을 바꾸거나 타입을 변경했는데 version과 migrate를 두지 않으면 오래된 브라우저 저장값 때문에 특정 사용자에게만 오류가 날 수 있습니다. 자세한 옵션은 공식 persist 문서에서 partialize, version, migrate, skipHydration을 함께 확인하세요.
5단계: 저장과 hydration은 같은 문제가 아니다
브라우저 storage에서 복원한 값과 서버가 만든 초기 HTML의 값이 다르면 hydration 경고나 화면 깜빡임이 생길 수 있습니다. 예를 들어 서버는 light theme로 렌더링했는데 첫 클라이언트 렌더에서 localStorage의 dark theme를 즉시 읽으면 결과가 달라집니다.
해결책은 앱 요구사항에 따라 다릅니다. 서버가 같은 초기값을 전달해 서버·클라이언트가 일치하게 만들거나, 저장값 복원이 끝난 뒤 해당 UI를 표시하거나, SSR이 필요 없는 일부 UI만 클라이언트에서 렌더링할 수 있습니다. skipHydration: true와 store.persist.rehydrate()로 복원 시점을 직접 제어할 수도 있지만 로딩 상태와 접근성을 함께 설계해야 합니다.
'use client'
import { useEffect, useState } from 'react'
export function PreferencesHydrator({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false)
useEffect(() => {
const hydration = usePreferencesStore.persist.rehydrate()
void Promise.resolve(hydration).finally(() => setReady(true))
}, [])
if (!ready) return <p aria-live='polite'>설정을 불러오는 중…</p>
return children
}
이 패턴을 쓰려면 persist 옵션에 skipHydration: true를 추가해야 합니다. 모든 화면을 hydration 뒤까지 숨기는 것이 정답은 아닙니다. 저장 상태가 실제로 첫 화면에 필요한지부터 다시 확인하세요.
6단계: Next.js에서는 요청별 store와 RSC 경계를 지킨다
Zustand store는 module state가 될 수 있습니다. Next.js 서버는 여러 요청을 동시에 처리하므로 서버 모듈의 단일 store를 모든 사용자와 공유하면 요청 간 상태가 섞일 수 있습니다. 공식 Next.js 가이드는 store를 요청별로 만들고 React Server Component가 store를 읽거나 쓰지 않도록 권장합니다. RSC는 hooks나 Context를 쓰지 않으며 사용자별 클라이언트 상태를 보관하는 곳이 아닙니다.
// stores/counter-store.ts
import { createStore } from 'zustand/vanilla'
type CounterStore = {
count: number
increment: () => void
}
export function createCounterStore(initialCount = 0) {
return createStore<CounterStore>()((set) => ({
count: initialCount,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
}
export type CounterStoreApi = ReturnType<typeof createCounterStore>
// providers/counter-store-provider.tsx
'use client'
import { createContext, useContext, useRef } from 'react'
import { useStore } from 'zustand'
import { createCounterStore, type CounterStoreApi } from '@/stores/counter-store'
const CounterStoreContext = createContext<CounterStoreApi | null>(null)
export function CounterStoreProvider({ children }: { children: React.ReactNode }) {
const storeRef = useRef<CounterStoreApi | null>(null)
if (storeRef.current === null) storeRef.current = createCounterStore()
return (
<CounterStoreContext.Provider value={storeRef.current}>
{children}
</CounterStoreContext.Provider>
)
}
export function useCounter<T>(selector: (state: ReturnType<CounterStoreApi['getState']>) => T) {
const store = useContext(CounterStoreContext)
if (!store) throw new Error('CounterStoreProvider가 필요합니다.')
return useStore(store, selector)
}
Provider의 범위는 상태 수명과 맞춰야 합니다. route 이동에도 유지할 상태는 공통 layout 아래에, 특정 화면을 떠날 때 초기화할 상태는 해당 화면 경계에 둡니다. 서버에서 계산한 초기값을 넘긴다면 서버 HTML과 클라이언트 첫 상태가 같도록 직렬화 가능한 값만 전달하세요.
실무 적용 전 점검표
- 이 값이 정말 여러 컴포넌트에서 공유되는지 확인합니다.
- store가 사용자 입력·UI 상태와 서버 데이터 캐시를 무분별하게 섞지 않는지 확인합니다.
- TypeScript store는
create<State>()(...)형태로 state와 action을 검사합니다. - 컴포넌트는 전체 store 대신 필요한 값을 selector로 구독합니다.
- 객체·배열 pick을 묶을 때만
useShallow필요성을 검토합니다. persist에는 저장할 필드만partialize하고 schema 변경 시 version·migrate를 추가합니다.- SSR HTML과 브라우저 저장값이 다른 경우 hydration 전략을 테스트합니다.
- Next.js 서버 module-global store를 만들지 않고 RSC에서 store를 읽거나 쓰지 않습니다.
- 새로고침, 로그아웃, 다중 탭, 저장값 구버전, route 이동을 테스트합니다.
공식 문서
- Zustand create API
- Zustand Beginner TypeScript Guide
- Prevent rerenders with useShallow
- Zustand persist middleware
- Zustand Setup with Next.js
이 순서를 한 번 적용한 뒤에는 Zustand persist 사용법과 Zustand 실무 주의사항에서 저장·분리 기준을 더 구체적으로 점검할 수 있습니다.
“Zustand 학습 로드맵: store·action·selector·persist 순서”에 대한 4개의 생각