상태관리 Playground
글로 읽는 것보다 직접 눌러보는 편이 빠르다. Zustand 스토어에 연결된 데모들을 만져보며 카운터·폼·파생 상태부터 비동기 액션, persist, 그리고 셀렉터가 리렌더링을 어떻게 줄이는지까지 체험해 본다.
카운터 상태
가장 기본적인 전역 상태다. 버튼을 누르면 스토어의 counter가 바뀌고, 이 값을 구독하는 컴포넌트가 리렌더링된다.
const counter = usePlaygroundStore((s) => s.counter)
const increment = usePlaygroundStore((s) => s.increment)
<button onClick={increment}>증가</button> // 상태 변경은 action으로 캡슐화폼 입력과 파생 상태
입력값을 전역 상태로 두면, 그 값을 구독하는 어디서든 동일한 값을 본다. 글자 수처럼 입력값에서 계산 가능한 값은 따로 저장하지 않고 파생시킨다.
파생 상태는 저장하지 않는다
글자 수·대문자 변환처럼 기존 상태로부터 계산되는 값은 별도 상태로 두지 않는다. 중복 저장하면 두 값이 어긋날 수 있다. 렌더링 때 계산하는 것이 단일 원천을 지키는 길이다.
비동기 액션과 로딩
액션 안에서 async/await로 비동기 작업을 처리할 수 있다. 시작할 때 isLoading을 켜고, 끝나면 결과를 저장하며 끄는 패턴이다. (아래는 800ms 지연으로 API 호출을 흉내 낸다.)
fetchRandom: async () => {
set({ isLoading: true })
await new Promise((r) => setTimeout(r, 800)) // API 호출 흉내
set({ randomValue: Math.floor(Math.random() * 100), isLoading: false })
}리스트(Todo) 상태
배열 상태는 불변성을 지켜 갱신한다. 추가는 [...todos, new], 수정은 map, 삭제는 filter로 새 배열을 만든다.
- Zustand 셀렉터 이해하기
- persist 미들웨어 실습
persist: 새로고침에도 유지
persist 미들웨어로 감싼 스토어는 상태를 자동으로 localStorage에 저장한다. 아래 좋아요 수를 올린 뒤 페이지를 새로고침해 보자 — 값이 그대로 남아 있다.
export const usePrefStore = create<PrefState>()(
persist(
(set) => ({
likes: 0,
like: () => set((s) => ({ likes: s.likes + 1 })),
}),
{ name: 'fsw-playground-pref' }, // localStorage 키
),
)셀렉터와 리렌더 최적화
Zustand의 핵심 성능 비결은 셀렉터 구독이다. 아래 두 컴포넌트는 같은 스토어를 구독하지만 방식이 다르다. 1번 섹션의 카운터를 올려 두 컴포넌트의 렌더 횟수를 비교해 보자.
좁은 셀렉터 (counter만 구독)
값: 0
렌더 횟수: 1
넓은 구독 (스토어 전체)
counter: 0
렌더 횟수: 1
// ✅ counter만 구독 → inputValue 변경에는 리렌더 안 됨
const counter = usePlaygroundStore((s) => s.counter)
// ❌ 스토어 전체 구독 → 어떤 상태가 바뀌어도 리렌더
const store = usePlaygroundStore()상태 공유 확인
아래 박스는 위 데모들과 별개의 컴포넌트(MirrorCounter) 지만, 같은 스토어를 구독하기 때문에 위에서 바꾼 값이 그대로 반영된다. props를 한 번도 전달하지 않았다는 점이 핵심이다.
props drilling 없는 공유
중간 컴포넌트들을 거쳐 props를 내려보내지 않아도, 필요한 컴포넌트가 스토어를 직접 구독한다. 깊은 트리에서 상태를 공유할 때 특히 강력하다.
스토어 구조
이 데모가 사용하는 스토어의 핵심 구조다. 상태와 그 상태를 바꾸는 action을 한곳에 모아 캡슐화한 것이 전부다.
export const usePlaygroundStore = create<PlaygroundState>((set) => ({
counter: 0,
increment: () => set((s) => ({ counter: s.counter + 1 })),
inputValue: '',
setInputValue: (value) => set({ inputValue: value }),
isLoading: false,
randomValue: null,
fetchRandom: async () => { /* 비동기 액션 */ },
todos: [],
addTodo: (text) => set((s) => ({ todos: [...s.todos, mk(text)] })),
toggleTodo: (id) => set((s) => ({ todos: s.todos.map(...) })),
removeTodo: (id) => set((s) => ({ todos: s.todos.filter(...) })),
}))정리
상태는 스토어에 모으고, 컴포넌트는 필요한 조각만 셀렉터로 가져간다. 파생 값은 저장하지 않고 계산하며, 지속이 필요하면 persist로 감싼다.
전역 상태의 핵심은 ‘하나의 원천을 여러 곳이 구독’하는 것이다.
상태는 스토어에 모으고, 컴포넌트는 필요한 조각만 셀렉터로 가져가라.