Zustand 사용 규칙
참고: https://www.heropy.dev/p/n74Tgc Persist 옵션 참고: https://www.heropy.dev/s/70
Zustand는 작고 빠른 React 상태 관리 라이브러리다. create 함수 하나로 상태(State)와
액션(Action)을 함께 정의하고, Provider 없이 동작하며, 선택자(Selector)로 필요한 상태만
구독해 불필요한 리렌더링을 피한다.
이 스킬은 Zustand로 스토어를 작성할 때 따라야 하는 패턴, 관례, 주의점을 모은다.
핵심 원칙 (먼저 읽기)
- 프로젝트에
CLAUDE.md나.claude/rules/zustand.md가 있으면 그 내용이 이 스킬보다 우선한다. 기존 코드가 있으면 파일 위치, 이름, 선언 형식을 먼저 확인하고 같은 스타일로 만든다. - 스토어 하나는 스토어 폴더 안의
<이름>.ts파일 하나에 정의한다. 스토어 폴더는 기존 프로젝트의src/stores->src/store->src/state순으로 찾고, 없으면src/stores를 만든다. 훅 이름은use이름Store다. 관심사별로 스토어 파일을 나눈다. (예:count.ts,user.ts,cart.ts) - TypeScript에서는 항상
combine미들웨어로 스토어를 만든다. 상태 타입을 직접 작성하지 않고 초기 상태에서 추론시킨다. 추론되지 않는 초깃값(null, 빈 배열)만satisfies와as로 명시한다. - 상태는
combine의 첫 번째 인수에, 액션은 두 번째 인수가 반환하는 객체의 최상위 함수로 정의한다. - 컴포넌트에서는 훅 호출 한 번에 상태(또는 액션) 하나만 선택한다. 값이 여러 개 필요하면 훅을 그 수만큼 호출한다. 선택한 값이 바뀔 때만 컴포넌트가 리렌더링된다.
- 상태 변경은
set콜백으로 현재 상태를 기준으로 한다.set은 전달한 객체를 기존 상태에 병합한다. - 스토어에는 사용자가 요청한 상태와 액션만 정의하고, 설명도 요청한 범위로 한정한다.
미들웨어는 요청한 기능에 필요한 것만 적용한다.
(새로고침 후에도 유지 ->
persist, 깊은 중첩 객체 변경 ->immer, 특정 상태 변경 감지 ->subscribeWithSelector, 상태 변화 추적 ->devtools) - 상대 경로는 같은 폴더 안의 파일을 가져올 때만 쓴다. 다른 폴더의 파일은
@/별칭으로 가져온다. 컴포넌트에서 스토어는@/stores/<이름>으로 가져온다.
스토어 파일 자동 생성 (트리거 입력)
사용자가 "OO 스토어 생성", "OO 스토어 만들어 줘" 처럼 스토어 이름과 생성 의도를 함께 말하면(예: "count 스토어 생성", "user 스토어 만들어 줘", "장바구니 스토어 생성"), 아래 절차로 스토어 파일을 직접 만든다. 코드 설명만 하지 말고 실제로 파일을 생성하라.
절차
규칙 확인. 프로젝트에
CLAUDE.md나.claude/rules/zustand.md가 있으면 그 내용이 이 스킬보다 우선한다. 기존 스토어가 있으면 파일 위치, 이름, 선언 형식을 확인하고 같은 스타일로 만든다.이름 정규화. 트리거에서 스토어 이름을 뽑아 카멜케이스로 만든다. 파일명은 그 이름, 훅 이름은
use+ PascalCase +Store.count스토어 생성 -> 파일count.ts, 훅useCountStoreuser profile스토어 -> 파일userProfile.ts, 훅useUserProfileStore- 한국어 이름이면 영문으로 옮긴다("장바구니" ->
cart). 모호하면 사용자에게 확인한다.
언어 판별. 프로젝트 루트(또는 가까운 상위)에
tsconfig.json이 있거나src아래.ts/.tsx파일이 있으면 TypeScript, 아니면 JavaScript로 본다. TypeScript면 확장자.ts, 아니면.js.폴더 결정. 다음 순서로 기존 폴더를 찾아 거기에 생성한다:
src/stores->src/store->src/state. 모두 없으면src/stores를 새로 만든다. (src가 없는 비표준 구조면 사용자에게 위치를 확인한다.) 같은 이름의 파일이 이미 있으면 덮어쓰지 말고 사용자에게 알린다.내용 작성. 아래 기본 템플릿을 채워 파일을 만든다. 사용자가 상태와 액션을 말했으면 그대로 반영하고, 말하지 않았으면 스토어 이름에서 유추한 최소한의 상태와 액션으로 뼈대만 만든다. 비즈니스 로직은 비워 둔다. 생성 후 파일 경로와 훅 이름을 한 줄로 보고한다.
검증.
{pm} run lint와{pm} run build(TypeScript 검사 포함)를 실행해 통과를 확인한다.
기본 템플릿 (TypeScript)
// src/stores/count.ts
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
export const useCountStore = create(
combine(
{
count: 0
},
set => ({
increase: () => set(state => ({ count: state.count + 1 }))
})
)
)
기본 템플릿 (JavaScript)
추론할 타입이 없으므로 combine 없이 create만 쓴다.
// src/stores/count.js
import { create } from 'zustand'
export const useCountStore = create(set => ({
count: 0,
increase: () => set(state => ({ count: state.count + 1 }))
}))
설치
{pm}은 프로젝트의 lock 파일로 판별한 패키지 매니저로 대체한다.pnpm-lock.yaml->pnpm,yarn.lock->yarn,bun.lockb또는bun.lock->bun,package-lock.json또는 lock 파일 없음 ->npm.{pmx}는 해당 패키지 매니저의 실행 명령으로 대체한다.npm->npx,pnpm->pnpm dlx,yarn->yarn dlx,bun->bunx.
{pm} add zustand
Zustand v5는 React 18 이상, TypeScript 4.5 이상이 필요하다.
immer 미들웨어를 쓸 때만 피어 의존성을 함께 설치한다.
{pm} add immer
스토어 작성 패턴
기본형
combine(초기상태, (set, get) => 액션객체) 형태다. 첫 번째 인수의 속성이 상태,
두 번째 인수가 반환하는 함수가 액션이다. 상태 타입은 초기 상태에서 추론된다.
// src/stores/count.ts
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
export const useCountStore = create(
combine(
{
count: 1
},
set => ({
increase: () => set(state => ({ count: state.count + 1 })),
decrease: () => set(state => ({ count: state.count - 1 }))
})
)
)
set(부분상태)는 전달한 객체를 기존 상태에 병합한다.- 현재 상태를 기준으로 바꿀 때는 콜백
set(state => ({ ... }))을 쓴다. get()은 액션 안에서 현재 상태를 읽을 때 쓴다. 단순 갱신은set콜백이 더 간결하다.
추론되지 않는 타입 명시 (satisfies + as)
초깃값이 null이거나 빈 배열이면 타입이 null, never[]로 좁게 추론되어 이후 값을
할당할 수 없다. satisfies로 초깃값이 타입에 맞는지 검사하고 as로 상태 타입을 넓힌다.
빈 배열은 [] as 타입[]로 명시한다.
// src/stores/user.ts
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
type User = {
email: string
displayName: string
} | null
const initialState = {
user: null satisfies User as User,
favorites: [] as string[],
isLoggedIn: false
}
export const useUserStore = create(
combine(initialState, set => ({
signIn: (user: User) => set({ user, isLoggedIn: true }),
signOut: () => set({ user: null, isLoggedIn: false }),
addFavorite: (id: string) =>
set(state => ({ favorites: [...state.favorites, id] }))
}))
)
상태 초기화 (initialState + resetState)
초기 상태를 변수로 분리하고 resetState 액션으로 되돌린다. set(initialState)는
병합이므로 액션은 그대로 남는다.
// src/stores/count.ts
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
const initialState = {
count: 1,
double: 2,
min: 0,
max: 99
}
export const useCountStore = create(
combine(initialState, set => ({
increase: () => set(state => ({ count: state.count + 1 })),
decrease: () => set(state => ({ count: state.count - 1 })),
resetState: (keys?: Array<keyof typeof initialState>) => {
// 전체 초기화
if (!keys) {
set(initialState)
return
}
// 일부 상태만 초기화
keys.forEach(key => {
set({ [key]: initialState[key] })
})
}
}))
)
컴포넌트에서 호출하는 부분만 보여 주는 조각이다.
const resetState = useCountStore(state => state.resetState)
<button => resetState()}>Reset All</button>
<button => resetState(['double', 'min'])}>Reset Double, Min</button>
액션 안에서 다른 액션 호출 (함수 호이스팅)
combine에서 get()은 상태만 반환하도록 추론되므로 get().다른액션()은 타입 에러가
난다. 액션을 함수 선언으로 뽑아 서로 호출하고, 반환 객체에 담는다.
// src/stores/count.ts
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
const initialState = {
count: 1,
double: 2
}
export const useCountStore = create(
combine(initialState, set => {
function increase() {
set(state => ({ count: state.count + 1 }))
increaseDouble()
}
function increaseDouble() {
set(state => ({ double: state.count * 2 }))
}
return { increase, increaseDouble }
})
)
비동기 액션
별도 미들웨어 없이 async 함수로 작성한다. 요청 전후로 로딩 상태를 함께 갱신한다.
// src/stores/user.ts
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
type User = {
email: string
displayName: string
} | null
export const useUserStore = create(
combine(
{
user: null satisfies User as User,
isLoading: false
},
set => ({
fetchUser: async (id: string) => {
set({ isLoading: true })
const res = await fetch(`/api/users/${id}`)
const user: User = await res.json()
set({ user, isLoading: false })
}
})
)
)
컴포넌트에서 사용하기
훅 호출 한 번에 하나씩 선택한다. 액션도 같은 방식으로 선택한다. 함수 참조는 바뀌지
않으므로 액션 선택은 리렌더링을 일으키지 않는다. 스토어는 @/stores/<이름> 별칭으로
가져온다.
// src/components/Counter.tsx
import { useCountStore } from '@/stores/count'
export default function Counter() {
const count = useCountStore(state => state.count)
const increase = useCountStore(state => state.increase)
const decrease = useCountStore(state => state.decrease)
return (
<>
<h2>{count}</h2>
<button
<button
</>
)
}
파생 값은 선택자 안에서 계산한다. 선택자가 원시 값을 반환하면 값이 같을 때 리렌더링되지 않는다. 컴포넌트 안의 호출 부분만 보여 주는 조각이다.
const double = useCountStore(state => state.count * 2)
const isMax = useCountStore(state => state.count >= state.max)
컴포넌트 외부에서 사용
스토어 훅은 정적 메서드를 가진다. 이벤트 핸들러, 라우터 로더, 유틸 함수 등 컴포넌트 밖에서 쓴다. 호출 예시 조각이다.
useCountStore.getState().count // 현재 상태 읽기 (구독하지 않음)
useCountStore.getState().increase() // 액션 호출
useCountStore.setState({ count: 0 }) // 상태 변경 (병합)
const unsubscribe = useCountStore.subscribe((state, prevState) => {
// 모든 상태 변경마다 실행
})
unsubscribe()
미들웨어
미들웨어는 create에 전달하는 콜백을 감싸 기능을 확장한다. 여러 개를 함께 쓸 때는
combine을 가장 안쪽에, devtools를 가장 바깥에 둔다. 아래는 합성 순서만 보여 주는
형태 조각이다.
import { create } from 'zustand'
import {
combine,
subscribeWithSelector,
persist,
devtools
} from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
export const useCountStore = create(
devtools(
persist(
subscribeWithSelector(immer(combine(초기상태, 액션))),
{ name: '스토리지_키' }
)
)
)
| 미들웨어 | 용도 | import 경로 |
|---|---|---|
combine |
상태 타입 추론. TypeScript에서 항상 사용 | zustand/middleware |
immer |
중첩 객체를 직접 대입으로 변경 | zustand/middleware/immer |
subscribeWithSelector |
특정 상태 변경만 구독 | zustand/middleware |
persist |
스토리지에 상태 저장 | zustand/middleware |
devtools |
Redux DevTools 확장으로 상태 변화 추적 | zustand/middleware |
immer: 중첩 객체 변경
user.relations[0].emails[0].domain처럼 깊은 속성을 바꿀 때 스프레드 복사 없이 직접
대입한다. set 콜백은 병합할 객체를 반환하지 않아도 된다.
// src/stores/user.ts
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
type User = {
email: string
displayName: string
} | null
const initialState = {
user: null satisfies User as User
}
export const useUserStore = create(
immer(
combine(initialState, set => ({
setDisplayName: (name: string) =>
set(state => {
if (state.user) {
state.user.displayName = name
}
})
}))
)
)
subscribeWithSelector: 특정 상태 변경 구독
subscribe(선택자, 리스너, 옵션?) 형태로 특정 상태가 바뀔 때만 리스너를 실행한다.
다른 상태를 함께 갱신하거나 부수 효과를 실행하는 데 쓴다. 세 번째 인수는
{ equalityFn, fireImmediately } 옵션이다. fireImmediately: true면 구독 시작 시
현재 값으로 리스너를 한 번 실행하므로 초기 동기화에 쓴다.
// src/stores/count.ts
import { create } from 'zustand'
import { combine, subscribeWithSelector } from 'zustand/middleware'
const initialState = {
count: 1,
double: 2
}
export const useCountStore = create(
subscribeWithSelector(
combine(initialState, set => ({
increase: () => set(state => ({ count: state.count + 1 })),
decrease: () => set(state => ({ count: state.count - 1 }))
}))
)
)
// 모듈 최상위에서 구독 (앱 시작 시 1회)
useCountStore.subscribe(
state => state.count, // 선택자
count => {
// 리스너
useCountStore.setState({ double: count * 2 })
},
{ fireImmediately: true } // 구독 시작 시 현재 값으로 1회 실행
)
컴포넌트 안에서만 구독할 때는 useEffect에서 시작하고 언마운트 시 해제한다.
컴포넌트 안의 호출 부분만 보여 주는 조각이다.
useEffect(() => {
const unsubscribe = useCountStore.subscribe(
state => state.count,
count => setDouble(count * 2)
)
return unsubscribe
}, [])
persist: 스토리지에 상태 저장
페이지를 새로고침하거나 다시 방문해도 상태를 유지한다. name은 필수이며 스토리지 키로
쓰이므로 프로젝트 안에서 유일해야 한다. 기본 스토리지는 localStorage다.
JSON으로 직렬화되는 상태만 저장되고, 액션은 코드에서 다시 정의되므로 저장 대상이 아니다.
// src/stores/count.ts
import { create } from 'zustand'
import { combine, persist } from 'zustand/middleware'
export const useCountStore = create(
persist(
combine(
{
count: 0
},
set => ({
increase: () => set(state => ({ count: state.count + 1 }))
})
),
{
name: 'count' // 스토리지 키 (필수, 유일)
}
)
)
옵션
| 옵션 | 기본값 | 용도 |
|---|---|---|
name |
(필수) | 스토리지에 저장될 키 |
storage |
localStorage |
세션 스토리지, IndexedDB 등 다른 스토리지 사용 |
partialize |
전체 상태 | 저장할 상태만 골라 반환 |
onRehydrateStorage |
없음 | 하이드레이션 전후에 실행할 콜백 |
skipHydration |
false |
초기 하이드레이션을 건너뛰고 수동으로 실행 |
version |
0 |
저장 구조가 바뀌면 올려서 이전 버전 데이터를 무시 |
migrate |
없음 | 버전이 다를 때 이전 데이터를 새 구조로 변환 |
merge |
얕은 병합 | 스토리지 상태를 현재 상태와 병합하는 방식 지정 |
하이드레이션(Hydration)은 스토리지에 저장된 상태를 현재 상태와 병합하는 과정이다.
아래 옵션 예제는 위 count 스토어의 옵션 객체 부분만 보여 주는 조각이다.
storage
createJSONStorage 헬퍼에 스토리지를 반환하는 함수를 전달한다. 직접 만든 스토리지는
getItem, setItem, removeItem을 가진 객체(StateStorage 인터페이스)면 된다.
import { createJSONStorage } from 'zustand/middleware'
// persist 옵션
{
name: 'count',
storage: createJSONStorage(() => sessionStorage) // 세션 스토리지 사용
}
partialize
저장할 상태만 포함하는 객체를 반환한다. 임시 UI 상태나 민감한 값을 저장에서 제외할 때 쓴다.
// persist 옵션
{
name: 'count',
partialize: state => ({
count: state.count // count만 저장
})
}
onRehydrateStorage
바깥 함수는 하이드레이션 직전에 현재 상태를 받아 실행되고, 반환한 함수는 하이드레이션이 끝난 뒤(또는 실패했을 때) 병합된 상태와 에러를 받아 실행된다.
// persist 옵션
{
name: 'count',
onRehydrateStorage: () => {
return (state, error) => {
if (error) {
console.error('하이드레이션 실패', error)
return
}
console.log('스토리지 상태와 병합 완료', state)
}
}
}
skipHydration
초기 하이드레이션을 건너뛴다. 원하는 시점에 persist.rehydrate()를 호출해 수동으로
실행한다. 서버 렌더링(SSR)에서 서버와 클라이언트의 첫 렌더 결과를 맞출 때 쓴다.
// persist 옵션
{
name: 'count',
skipHydration: true
}
// src/components/RehydrateButton.tsx
import { useCountStore } from '@/stores/count'
export default function RehydrateButton() {
return (
<button => useCountStore.persist.rehydrate()}>Rehydrate</button>
)
}
version + migrate
저장 구조가 크게 바뀌면 version을 올린다. 버전이 다른 스토리지 데이터는 무시된다.
이전 데이터를 살려야 하면 migrate에서 새 구조로 변환해 반환한다. migrate는
하이드레이션 시 스토리지의 버전이 현재 version과 다를 때 실행된다.
// src/stores/count.ts
import { create } from 'zustand'
import { combine, persist } from 'zustand/middleware'
interface StateV0 {
count: number
double: number
}
interface StateV1 {
amount: number
multiplier: number
}
export const useCountStore = create(
persist(
combine(
{
amount: 0,
multiplier: 0
},
set => ({
increase: () => set(state => ({ amount: state.amount + 1 }))
})
),
{
name: 'count',
version: 1, // 기본값 0
migrate: (persistedState, version) => {
if (version === 0) {
const oldState = persistedState as StateV0
const newState: StateV1 = {
amount: oldState.count,
multiplier: oldState.double
}
return newState
}
return persistedState as StateV1
}
}
)
)
merge
기본은 얕은 병합이다. 중첩 객체 상태를 깊게 병합해야 하면 Lodash의 merge를 쓴다.
migrate와 함께 지정하면 migrate의 반환값을 persistedState로 받아 그다음에 실행된다.
Lodash merge는 첫 번째 인수를 제자리에서 변조하므로 빈 객체를 첫 번째 인수로 준다.
currentState를 직접 변조하면 같은 참조가 반환되어 persist.rehydrate()나 비동기
스토리지에서 구독자(컴포넌트)에 변경이 알려지지 않는다.
{pm} add lodash-es
{pm} add -D @types/lodash-es
import { merge } from 'lodash-es'
// persist 옵션
{
name: 'count',
merge: (persistedState, currentState) =>
merge({}, currentState, persistedState) // 새 객체에 깊은 병합 (currentState를 직접 변조하지 않음)
}
persist API
스토어 훅의 persist 속성으로 하이드레이션을 제어한다.
| 메서드 | 용도 |
|---|---|
persist.rehydrate() |
스토리지 상태를 다시 병합 |
persist.hasHydrated() |
하이드레이션 완료 여부 |
persist.onHydrate(listener) |
하이드레이션 시작 시 실행할 리스너 등록, 해제 함수 반환 |
persist.onFinishHydration(listener) |
하이드레이션 완료 시 실행할 리스너 등록, 해제 함수 반환 |
persist.getOptions() |
현재 persist 옵션 읽기 |
persist.setOptions(options) |
persist 옵션 일부 변경 (예: name, storage) |
persist.clearStorage() |
스토리지에서 저장된 상태 삭제 |
devtools: 상태 변화 추적
크롬 확장 Redux DevTools로 상태 변화를 추적한다. 가장 바깥에 둔다.
// src/stores/count.ts
import { create } from 'zustand'
import { combine, persist, devtools } from 'zustand/middleware'
export const useCountStore = create(
devtools(
persist(
combine(
{
count: 0
},
set => ({
increase: () => set(state => ({ count: state.count + 1 }))
})
),
{ name: 'count' }
)
)
)
작성 체크리스트 (MANDATORY)
새 스토어 또는 컴포넌트 사용 코드를 작성한 뒤, 종료 전에 확인한다.
-
CLAUDE.md,.claude/rules/zustand.md, 기존 스토어 스타일을 확인하고 따랐다 - 파일 위치는 규칙으로 정한 스토어 폴더 안의
<이름>.ts, 훅 이름은use이름Store - 컴포넌트에서 스토어를
@/stores/<이름>별칭으로 가져왔다 - TypeScript 스토어는
combine으로 만들었고 상태 타입을 직접 작성하지 않았다 -
null이나 빈 배열 초깃값은satisfies와as로 타입을 명시했다 - 액션은
combine두 번째 인수가 반환하는 객체의 최상위 함수다 - 컴포넌트에서 훅 호출 한 번에 상태(액션) 하나만 선택했다
- 상태 갱신은
set콜백으로 현재 상태를 기준으로 했다 - 액션 간 호출은 함수 선언(호이스팅)으로 처리했다
- 사용자가 요청한 상태, 액션, 미들웨어만 포함했다
- 미들웨어 순서는
devtools(persist(subscribeWithSelector(immer(combine(...))))) -
persist의name은 프로젝트 안에서 유일하다 -
persist로 일부 상태만 저장한다면partialize를 썼다 -
persist의 저장 구조를 바꿨다면version을 올리고 필요하면migrate를 작성했다 -
persist의merge를 지정했다면currentState를 직접 변조하지 않았다 -
{pm} run lint통과 -
{pm} run build통과 (TypeScript 검사 포함)
함께 보는 스킬
서버 데이터(캐싱, 재검증, 무효화)는 tanstack-react-query-use의 영역이다. Zustand는
클라이언트 전역 상태(UI 상태, 폼 임시값, 인증 정보 등)를 담당한다. Next.js에서 스토어를 쓰는
컴포넌트는 클라이언트 컴포넌트('use client')여야 하며, 스토어 파일 자체는 그대로 쓴다.
| 필요한 것 | 스킬 |
|---|---|
| 프로젝트 기반 설정 (Tailwind, 경로 별칭, ESLint + Prettier) | react-vite-scaffold |
| 라우팅 (React Router Data Mode) | react-router-use |
| 서버 데이터 fetching / 캐싱 | tanstack-react-query-use |
| Next.js 프로젝트 기반 설정 | react-next-scaffold |
| Vite React 프로젝트를 Next.js로 전환 | react-vite-to-next-migration |
| 성능, 접근성, SEO 측정 | lighthouse |