Zustand 핵심 정리

다음은 v5의 주요 업데이트 내용입니다.
- React v18 이상 필요
- TypeScript v4.5 이상 필요
- UMD/SystemJS 지원 중단
- ES5 지원 중단
- 기본 내보내기(Default Export) 삭제
- Persist 미들웨어가 초기 상태를 바로 스토리지에 저장하지 않음
use-sync-external-store
패키지가 피어 종속성으로 변경, 필요한 경우 직접 설치setState
의 상태 대체(replace
)가 더욱 엄격하게 동작
# 개요
# 스토어란
스토어(Store)는 애플리케이션의 여러 상태(State)를 중앙에서 관리하는 패턴을 말합니다.(상태는 관리하는 데이터를 의미합니다)
이를 통해 컴포넌트 간 데이터를 쉽게 공유하고 데이터 변경을 감지해 자동으로 렌더링(반응성)할 수도 있습니다.
대부분의 애플리케이션은 다음과 같이, 최상위 컴포넌트(App)를 기준으로 하는 여러 하위 컴포넌트(A1, B1, B2 등)로 구성되어 있습니다.
zoom_out_map
만약 컴포넌트 간에 공유해야 하는 데이터가 있다면, 기본적으로 부모와 자식 컴포넌트 간 데이터 전달이 가능합니다.
그리고 이렇게 데이터를 전달하는 것을 Props(Emits) 방식이라고 부릅니다.
zoom_out_map
그런데 이러한 방식은 부모와 자식 관계 이상의 중첩된 컴포넌트 구조에서, 불필요하게 데이터를 취급하는 중간 단계의 컴포넌트가 생기고 그만큼 컴포넌트 간 결합도가 높아지며 유지/보수를 매우 어렵게 만듭니다.
다음 이미지에서, App 컴포넌트와 B3-1 컴포넌트 간 데이터를 공유하려면, 중간에 B1과 B2 컴포넌트를 거쳐야 하며,
zoom_out_map
더 나아가 다음과 같이 A2 컴포넌트와 B3-1 컴포넌트 간 데이터를 공유하려면, 훨씬 더 많은 중간 단계의 컴포넌트를 거쳐야 합니다.
zoom_out_map
이런 상황을, 속성(데이터)이 여러 컴포넌트를 관통하는 것 같다고 해서 'Prop Drilling'이라고 부릅니다.
zoom_out_map
그래서 우리는 스토어(Store)를 사용해, 컴포넌트 간 공유할 데이터를 중앙에서 관리할 수 있습니다.
이 방식은 데이터를 전달하는 중간 단계 컴포넌트가 필요치 않으므로, 컴포넌트 간 결합도를 낮추고 유지/보수를 쉽게 만듭니다.
다음 이미지에서 A2와 B3-1 컴포넌트 간 데이터는 스토어(Store)를 통해 공유하기 때문에, 다른 중간 단계 컴포넌트는 필요치 않습니다.
zoom_out_map
zoom_out_map
# Redux Toolkit(RTK) vs Zustand 비교
Redux는 JavaScript 애플리케이션의 상태를 예측 가능하게 관리하기 위한 라이브러리입니다.
Redux는 단일 진실의 원천(Single Source of Truth) 원칙을 기반으로 하여, 애플리케이션의 전체 상태를 하나의 Store에 저장하고, 상태 변경은 순수한 Action 객체를 통해 Reducer 함수로 처리합니다. Dispatch 함수를 통해 Action을 Store에 전달하는 단방향 데이터 흐름을 특징으로 합니다.
Redux Toolkit(RTK)은 Redux의 공식 권장 도구로, Redux의 복잡성을 크게 줄였습니다.createSlice
를 통해 액션과 리듀서를 한 번에 정의할 수 있고, configureStore
로 구성을 간소화했습니다.
또한 createAsyncThunk
로 비동기 액션을 보다 쉽게 처리할 수 있으며, Immer가 내장되어 불변성 관리가 자동화되었습니다.
다만, RTK가 보일러플레이트를 줄였음에도 여전히 남아있는 구조가 있습니다.
상태를 관리하는 과정이 분리되어, 일반적으로 여러 파일에 걸쳐 코드를 작성해야 하고, 이는 코드의 가독성과 유지보수성을 떨어뜨립니다.
- Store 설정:
configureStore
와 Slice 연결 - Slice 정의:
createSlice
를 사용한 State, Reducer, Action 정의 - 컴포넌트 연결:
useSelector
와useDispatch
사용
아래 예제는 count
, double
상태를 관리하는 Redux 코드로, 하나의 상태 변경을 위해 여러 파일과 함수들이 필요합니다.
Action, Reducer, Selector가 분리되어 관리되는 복잡성을 확인할 수 있습니다.
1npm i react-redux @reduxjs/toolkit
12345678910111213141516171819202122232425262728import { createSlice } from '@reduxjs/toolkit' interface CountState { count: number double: number } const initialState: CountState = { count: 0, double: 0 } export const countSlice = createSlice({ name: 'count', initialState, reducers: { increase: state => { state.count += 1 state.double = state.count * 2 }, decrease: state => { state.count -= 1 state.double = state.count * 2 } } }) export const { increase, decrease } = countSlice.actions export default countSlice.reducer
1234567891011import { configureStore } from '@reduxjs/toolkit' import countReducer from './countSlice' export const store = configureStore({ reducer: { count: countReducer } }) export type RootState = ReturnType<typeof store.getState> export type AppDispatch = typeof store.dispatch
12345678910import { createRoot } from 'react-dom/client' import { Provider } from 'react-redux' import { store } from './store' import App from './App' createRoot(document.getElementById('root')!).render( <Provider store={store}> <App /> </Provider> )
1234567891011121314151617import { useSelector, useDispatch } from 'react-redux' import { RootState } from '@/store' import { increase, decrease } from '@/store/countSlice' export default function App() { const { count, double } = useSelector((state: RootState) => state.count) const dispatch = useDispatch() return ( <> <h2>Count: {count}</h2> <h2>Double: {double}</h2> <button onClick={() => dispatch(increase())}>+1</button> <button onClick={() => dispatch(decrease())}>-1</button> </> ) }
이번에는 Zustand(주스탠드)를 살펴봅시다.
Zustand는 작고 빠르며 확장 가능한 React 프로젝트에서 사용하는 상태 관리(Store) 라이브러리로, 간결한 코드 패턴을 핵심 철학으로 합니다.
상태와 액션을 통합해 React Context나 Provider 없이도 전역 상태를 사용할 수 있으며, Hooks 기반으로 작동하고 클로저(Closure)를 활용해 Store를 생성합니다.create
함수 하나로 State와 Action을 통합 정의할 수 있고, 반환되는 훅으로 상태 접근과 액션 호출을 동시에 처리할 수 있습니다.
또한 필요한 상태만 선택적으로 구독해 불필요한 리렌더링을 방지할 수 있습니다.
Redux Toolkit(RTK)을 사용하는 예제와 같이 count
, double
상태를 관리하는 Zustand 코드입니다.
Store 정의부터 컴포넌트 사용까지의 매우 간결한 코드를 볼 수 있습니다.
1npm i zustand
1234567891011121314151617181920212223import { create } from 'zustand' import { combine } from 'zustand/middleware' export const useCountStore = create( combine( { count: 0, double: 0 }, set => ({ increase: () => set(state => ({ count: state.count + 1, double: (state.count + 1) * 2 })), decrease: () => set(state => ({ count: state.count - 1, double: (state.count - 1) * 2 })) }) ) )
1234567891011121314151617import { useCountStore } from '@/store/countStore' export default function App() { const count = useCountStore(state => state.count) const double = useCountStore(state => state.double) const increase = useCountStore(state => state.increase) const decrease = useCountStore(state => state.decrease) return ( <> <h2>Count: {count}</h2> <h2>Double: {double}</h2> <button onClick={increase}>+1</button> <button onClick={decrease}>-1</button> </> ) }
Redux Toolkit과 Zustand의 차이점을 간단하게 비교해봤습니다.
Redux는 강력한 구조화와 예측 가능한 상태 관리를 제공하지만, 여러 파일에 걸친 복잡한 설정이 필요합니다.
반면 Zustand는 단일 파일에서 간결하게 상태 관리를 구현할 수 있어 개발 생산성을 크게 향상시킵니다.
비교 | Redux Toolkit (RTK) | Zustand | 선택 이유 |
---|---|---|---|
Store 정의 | createSlice 및 configureStore 분리 | create 함수 하나로 통합 | 보일러플레이트 코드 대폭 감소 |
액션/리듀서 | reducers 객체 내 정의 액션은 별도 export 필요 |
set 함수를 사용하는 일반 함수로 정의 | 액션과 리듀서의 경계가 사라져 직관적 |
파일 구조 | Slice 파일, Store 설정 파일 등 다수 | Store 정의 파일 하나로 충분 | 프로젝트 구조가 단순하고 관리 용이 |
불변성 관리 | Immer를 통한 간접적인 강제 | set 함수 내에서 직접 불변성 처리 가능 | 코드 작성 시 상태 흐름이 명확함 |
Provider | 필수 (Context 기반) | 불필요 (클로저 기반) | 애플리케이션 최상위 구조가 단순해짐 |
선택적 구독 | useSelector 내에서 구현 필요 | 훅 사용 시 기본적으로 부분 상태만 구독 | 불필요한 리렌더링 방지 및 성능 최적화 용이 |
비동기 처리 | createAsyncThunk 또는 Redux-Saga/Thunk 미들웨어 |
set 함수 내에서 간단히 처리 미들웨어로 확장 가능 |
복잡한 설정 없이 비동기 로직 구현 가능 |
# 시작하기
# 설치
기본 프로젝트 구성은 React 프로젝트 시작하기 w. Vite를 참고하세요.
다음과 같이 Zustand를 프로젝트에 설치합니다.
1npm i zustand
# 기본 사용
create
함수로 스토어를 생성합니다.create
함수의 콜백은 set
, get
매개변수를 가지며, 이를 통해 상태를 변경하거나 조회할 수 있습니다.create
함수의 콜백이 반환하는 객체에서의 속성은 상태(State)이고, 메소드는 액션(Action)이라고 부릅니다.create
함수 호출에서 반환하는 스토어 훅(Hook)은, useCountStore
와 같이 use
접두사와 Store
접미사로 명명해 각 컴포넌트에서 사용할 수 있습니다.
12345678import { create } from 'zustand' export const use이름Store = create((set, get) => { return { 상태: 초깃값, 액션: 함수 } })
set
, get
매개변수(함수)는 다음과 같이 각 액션에서 사용할 수 있습니다.get
함수를 호출하면, 상태와 액션을 가진 스토어 객체(state
)를 얻을 수 있습니다.
또한 set
함수를 호출(변경할 상태를 속성으로 포함한 객체를 전달)하면, 상태를 변경할 수 있습니다.
1234567891011121314import { create } from 'zustand' export const use이름Store = create((set, get) => { return { 상태: 초깃값, 액션: () => { const state = get() const { 상태 } = state set({ 상태: 상태 + 1 }) } } })
set
함수를 호출할 때 콜백을 사용하면, get
함수를 사용하지 않아도 바로 스토어 객체(state
)를 얻을 수 있습니다.
변경할 상태를 속성으로 포함한 객체를 콜백에서 반환해야 합니다.
123456789101112import { create } from 'zustand' export const use이름Store = create(set => { return { 상태: 초깃값, 액션: () => { set(state => ({ 상태: state.상태 + 1 })) } } })
컴포넌트에서 스토어 훅(use이름Store
)을 가져와 호출할 때 선택자 함수를 전달해 원하는 상태나 액션을 얻을 수 있습니다.
또한 상태는 반응형이기 때문에, 상태가 변경되면 컴포넌트가 다시 렌더링됩니다.
같은 스토어의 다른 상태를 다른 컴포넌트가 불필요하게 리렌더링되지 않도록, 한 번의 훅 호출로 하나의 상태(액션)만 가져와야 합니다.
1234import { use이름Store } from '~/store/스토어' const 선택자함수 = state => state.상태_액션 const 상태_액션 = use이름Store(선택자함수)
123456789101112import { use이름Store } from '~/store/스토어' export default function 컴포넌트() { const 상태 = use이름Store(state => state.상태) const 액션 = use이름Store(state => state.액션) return ( <> <h2>{상태}</h2> <button onClick={액션}>+</button> </> ) }
만약 선택자 함수 없이 스토어 훅을 호출하면 개별 상태(액션)가 아닌 스토어 객체를 얻을 수 있습니다.
하지만 이는 사용하지 않는 상태가 변경돼도 해당 스토어를 사용하는 모든 컴포넌트가 리렌더링 되기 때문에 주의해야 합니다!
1234567891011import { use이름Store } from '~/store/스토어' export default function 컴포넌트() { const 스토어 = use이름Store() return ( <> <h2>{스토어.상태}</h2> <button onClick={스토어.액션}>+</button> </> ) }
간단한 예제를 살펴보겠습니다.
기본적으로 프로젝트의 store
폴더에서 각 스토어를 생성합니다.
타입스크립트를 사용할 때는, create
함수의 제네릭으로 상태(State)와 액션(Action) 타입을 전달합니다.
1create<타입>()
get
함수를 호출하면, 상태와 액션이 포함된 스토어 객체를 얻을 수 있습니다.
이를 통해, 각 액션에서 상태의 값을 얻을 수 있습니다.
1234567891011121314151617import { create } from 'zustand' export const useCountStore = create<{ count: number increase: () => void decrease: () => void }>((set, get) => ({ count: 1, increase: () => { const { count } = get() set({ count: count + 1 }) }, decrease: () => { const { count } = get() set({ count: count - 1 }) } }))
get
함수를 사용하지 않고, set
함수의 콜백을 사용하면 더 간결하게 상태를 변경할 수 있습니다.
1234567891011import { create } from 'zustand' export const useCountStore = create<{ count: number increase: () => void decrease: () => void }>(set => ({ count: 1, increase: () => set(state => ({ count: state.count + 1 })), decrease: () => set(state => ({ count: state.count - 1 })) }))
생성한 스토어를 다음과 같이 컴포넌트에서 사용할 수 있습니다.
1234567891011121314import { useCountStore } from './store/count' export default function App() { const count = useCountStore(state => state.count) const increase = useCountStore(state => state.increase) const decrease = useCountStore(state => state.decrease) return ( <> <h2>{count}</h2> <button onClick={increase}>+1</button> <button onClick={decrease}>-1</button> </> ) }
# 다중 상태 선택(useShallow)
앞서 설명한 것처럼 스토어를 사용하는 모든 컴포넌트의 불필요한 리렌더링을 방지하기 위해, 컴포넌트에서는 한 번에 하나씩만 상태(액션)를 가져와야 하지만, useShallow
훅을 사용하면 여러 상태(액션)을 한 번에 객체나 배열로 가져올 수 있습니다.
스토어 훅에서 Zustand의 useShallow
훅을 중첩 호출해 선택자 함수를 전달합니다.
이때 선택자 함수는 사용하고자 하는 상태(액션)을 포함하는 객체나 배열을 반환해야 합니다.
먼저 객체를 반환하는 방식에 대해서 살펴봅시다.
12345678import { useShallow } from 'zustand/shallow' const { 상태, 액션 } = use이름Store( useShallow(state => ({ 상태: state.상태, 액션: state.액션 })) )
다음 예제에서는 count
, increase
, decrease
상태(액션)을 하나씩 가져오지 않고, 한 번에 가져옵니다.
객체 구조 분해 할당으로 사용할 상태(액션)만 꺼낼 수도 있겠지만, 한 번에 객체로 모아 처리하는 경우 유용할 수 있습니다.
123456789101112131415161718192021222324import { useShallow } from 'zustand/shallow' import { useCountStore } from './store/count' export default function App() { // const count = useCountStore(state => state.count) // const increase = useCountStore(state => state.increase) // const decrease = useCountStore(state => state.decrease) // const { count, increase, decrease } = useCountStore(state => ({ const countState = useCountStore( useShallow(state => ({ count: state.count, increase: state.increase, decrease: state.decrease })) ) return ( <> <h2>{countState.count}</h2> <button onClick={countState.increase}>+1</button> <button onClick={countState.decrease}>-1</button> </> ) }
선택자 함수에서 객체 대신 배열을 반환할 수도 있습니다.
순서대로 좀 더 간결하게 작성하거나, 한 번에 배열로 가져오는 경우 유용할 수 있습니다.
12345import { useShallow } from 'zustand/shallow' const [상태, 액션] = use이름Store( useShallow(state => [state.상태, state.액션]) )
12345678910111213141516import { useShallow } from 'zustand/shallow' import { useCountStore } from './store/count' export default function App() { // const [count, increase, decrease] = useCountStore( const countState = useCountStore( useShallow(state => [state.count, state.increase, state.decrease]) ) return ( <> <h2>{countState[0]}</h2> <button onClick={countState[1]}>+1</button> <button onClick={countState[2]}>-1</button> </> ) }
# 액션 분리
만약 여러 컴포넌트에서 단일 스토어의 액션을 많이 사용한다면, 액션를 분리해 관리하는 패턴을 활용해 보세요.
다음과 같이 actions
객체 안에서 모든 액션을 관리하면, 각 컴포넌트에서 필요한 액션만 가져오기 쉽습니다.
123456789101112131415import { create } from 'zustand' export const useCountStore = create<{ count: number actions: { increase: () => void decrease: () => void } }>(set => ({ count: 1, actions: { increase: () => set(state => ({ count: state.count + 1 })), decrease: () => set(state => ({ count: state.count - 1 })) } }))
12345678910111213import { useCountStore } from './store/count' export default function App() { const count = useCountStore(state => state.count) const { increase, decrease } = useCountStore(state => state.actions) return ( <> <h2>{count}</h2> <button onClick={increase}>+1</button> <button onClick={decrease}>-1</button> </> ) }
# 상태 초기화
만약 상태를 초깃값으로 되돌리는 기능이 필요한 경우, 다음과 같이 resetState
함수를 추가해 사용할 수 있습니다.
액션을 제외한 상태만 초기화하는 것이니, 상태와 액션을 분리해서 타입과 초깃값을 작성합니다.
123456789101112131415161718192021222324252627282930import { create } from 'zustand' interface State { count: number double: number min: number max: number } interface Actions { actions: { increase: () => void decrease: () => void resetState: () => void } } const initialState: State = { count: 1, double: 2, min: 0, max: 99 } export const useCountStore = create<State & Actions>(set => ({ ...initialState, actions: { increase: () => set(state => ({ count: state.count + 1 })), decrease: () => set(state => ({ count: state.count - 1 })), resetState: () => set(initialState) } }))
전체 상태를 초기화하는 것에 더해 일부 상태도 초기화하려면, 다음과 같이 resetState
함수를 수정할 수 있습니다.
12345678910111213141516171819202122232425262728293031323334353637383940import { create } from 'zustand' interface State { count: number double: number min: number max: number } interface Actions { actions: { increase: () => void decrease: () => void resetState: (keys?: Array<keyof State>) => void } } const initialState: State = { count: 1, double: 2, min: 0, max: 99 } export const useCountStore = create<State & Actions>(set => ({ ...initialState, actions: { increase: () => set(state => ({ count: state.count + 1 })), decrease: () => set(state => ({ count: state.count - 1 })), resetState: keys => { // 전체 상태 초기화 if (!keys) { set(initialState) return } // 일부 상태 초기화 keys.forEach(key => { set({ [key]: initialState[key] }) }) } } }))
다음과 같이 초기화 액션을 호출합니다.
12345678910111213import { useCountStore } from './store/count' export default function resetState() { const { resetState } = useCountStore(state => state.actions) return ( <> <button onClick={() => resetState()}>Reset All!</button> <button onClick={() => resetState(['double', 'min'])}> Reset Double, Min! </button> </> ) }
# 상태 삭제
set
함수의 두 번째 인수(기본값 false
)로 true
를 전달하면, 상태를 병합하지 않고 덮어씁니다.
Lodash의 omit
함수는 특정 속성들을 제외한 새로운 객체를 반환합니다.
이를 조합하면, 특정 상태들을 삭제할 수 있습니다.
12npm i lodash-es npm i -D @types/lodash-es
123456789101112131415161718192021222324252627282930313233import { create } from 'zustand' import { omit } from 'lodash-es' interface State { count: number double: number min: number max: number } interface Actions { actions: { increase: () => void decrease: () => void deleteState: (keys: Array<keyof State>) => void } } const initialState: State = { count: 1, double: 2, min: 0, max: 99 } export const useCountStore = create<State & Actions>(set => ({ ...initialState, actions: { increase: () => set(state => ({ count: state.count + 1 })), decrease: () => set(state => ({ count: state.count - 1 })), deleteState: keys => { set(state => omit(state, keys), true) } } }))
다음과 같이 삭제할 상태 목록을 전달해 액션을 호출합니다.
123456789101112import { useCountStore } from './store/count' export default function DeleteState() { const { deleteState } = useCountStore(state => state.actions) return ( <> <button onClick={() => deleteState(['min', 'max'])}> Delete Min, Max! </button> </> ) }
# 미들웨어
Zustand는 미들웨어(Middleware)라는 것을 사용해, 스토어의 추가 기능(타입 추론, 중첩 객체 변경 등)을 확장할 수 있습니다.
다중 미들웨어를 작성할 때는 일부 중첩 순서가 중요할 수 있습니다.
123456789101112131415// 미들웨어 없이 create(콜백) // 단일 미들웨어 import { 미들웨어 } from '미들웨어' create(미들웨어(콜백)) // 다중 미들웨어 import { 미들웨어A } from '미들웨어A' import { 미들웨어B } from '미들웨어B' import { 미들웨어C } from '미들웨어C' create(미들웨어A(미들웨어B(미들웨어C(콜백)))) // 타입을 사용하는 경우 create(미들웨어A(미들웨어B(미들웨어C<타입>(콜백))))
# 상태의 타입 추론 (Combine)
타입스크립트를 사용할 때, 상태 타입을 직접 작성하지 않고 추론하도록 combine
미들웨어를 사용할 수 있습니다.combine
미들웨어는 첫 번째 인수로 추론할 상태를 받고, 두 번째 인수로 set
, get
매개변수를 포함하는 액션 함수를 받습니다.
12345import { combine } from 'zustand/middleware' const state = { ...상태 } const actions = (set, get) => ({ ...액션 }) combine(state, actions)
앞서 살펴본 상태 삭제 예시에서 combine
미들웨어를 사용하면, 다음과 같이 작성할 수 있습니다.
다음 예제의 모든 상태는 숫자 타입(number
)으로 추론합니다.
123456789101112131415161718192021import { create } from 'zustand' import { combine } from 'zustand/middleware' import { omit } from 'lodash-es' const initialState = { count: 1, double: 2, min: 0, max: 99 } export const useCountStore = create( combine(initialState, set => ({ actions: { increase: () => set(state => ({ count: state.count + 1 })), decrease: () => set(state => ({ count: state.count - 1 })), deleteState: (keys: Array<keyof typeof initialState>) => { set(state => omit(state, keys), true) } } })) )
추론 가능하지 않은 타입은 직접 작성해야 합니다.
타입 만족(satisfies
)이나 타입 단언(as
) 키워드를 활용해 타입을 작성하면 됩니다.
12345678910111213141516171819202122232425262728293031import { create } from 'zustand' import { combine } from 'zustand/middleware' type User = { email: string displayName: string isValid: boolean } | null const initialState = { // user: {} satisfies User as User, // Error! user: null satisfies User as User, isLoggedIn: false } export const useUserStore = create( combine(initialState, set => ({ actions: { login: () => { set({ user: { email: 'thesecon@gmail.com', displayName: 'HEROPY', isValid: true, phone: 12345678 // Error! } }) } } })) )
# 액션 호출 in 액션
액션 함수 내부에서 다른 액션을 호출할 때, get()
함수의 반환으로 액션을 가져와 호출할 수 있습니다.
하지만, combine
미들웨어를 사용하면, get()
함수가 액션 타입을 추론하지 못합니다.
다음 예제는 정상적으로 잘 동작하지만, 타입 에러가 발생합니다.
123456789101112131415161718192021import { create } from 'zustand' import { combine } from 'zustand/middleware' const initialState = { count: 1, double: 2 } export const useCountStore = create( combine(initialState, (set, get) => ({ actions: { increase: () => { set(state => ({ count: state.count + 1 })) get().actions.increaseDouble() // Error - '{ count: number; double: number; }' 형식에 'actions' 속성이 없습니다.ts(2339) }, increaseDouble: () => { set(state => ({ double: state.count * 2 })) } } })) )
이를 해결하기 위해, 다음과 같이 액션을 별도 함수로 작성합니다.
그러면 함수 호이스팅을 활용하니, 액션 안에서 다른 액션을 쉽게 호출할 수 있습니다.
12345678910111213141516171819// ... export const useCountStore = create( combine(initialState, set => { function increase() { set(state => ({ count: state.count + 1 })) increaseDouble() // OK! } function increaseDouble() { set(state => ({ double: state.count * 2 })) } return { actions: { increase, increaseDouble } } }) )
# 중첩된 객체 변경 (Immer)
다음 예시와 같이, user
객체(상태)에서 displayName
속성만 변경하는 액션을 작성할 수 있습니다.
여기서 set
함수는 상태 자체를 변경해야 하므로, 특정 객체에서 일부 속성만 변경하려면 새로운 객체를 할당하고 기존 속성은 복사해야 합니다.
그런데 만약 user.relations[0].emails[0].domain
과 같이 다중 중첩 객체에서 특정 하위 속성을 변경하려면, 이러한 작업은 매우 복잡해집니다.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546import { create } from 'zustand' interface State { user: { email: string displayName: string isValid: boolean } | null } interface Actions { actions: { signIn: () => void setDisplayName: (name: string) => void } } const initialState: State = { user: null } export const useUserStore = create<State & Actions>(set => ({ ...initialState, actions: { signIn: () => { set({ user: { email: 'thesecon@gmail.com', displayName: 'HEROPY', isValid: true } }) }, setDisplayName: name => { set(state => { if (state.user) { return { user: { ...state.user, displayName: name } } } return {} // 병합할 상태, 상태 변경 없음! }) } } }))
Immer 미들웨어를 사용하면, 중첩된 객체 상태를 보다 쉽게 변경할 수 있습니다.
단, Immer 라이브러리를 설치해야 합니다.
이는 Zustand의 Immer 미들웨어를 사용하기 위해 설치해야 하는 피어 의존성(Peer Dependency)입니다.
1npm i immer
다시 돌아와, Immer 미들웨어를 적용합니다.
이제 set
함수의 콜백에서 객체의 속성에 직접 접근해 값을 변경할 수 있으며, 병합할 상태 객체의 반환이 없어도 됩니다.user.relations[0].emails[0].domain = 'com'
과 같이 다중 중첩 객체의 속성도 쉽게 변경할 수 있습니다.
12345678910111213141516171819import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' // ... export const useUserStore = create( immer<State & Actions>(set => ({ ...initialState, actions: { // ... setDisplayName: name => { set(state => { if (state.user) { state.user.displayName = name } }) } } })) )
앞서 살펴본 combine
과 immer
미들웨어를 같이 사용하면, 다음과 예제와 같이 작성할 수 있습니다.
123456789101112131415161718192021222324import { create } from 'zustand' import { combine } from 'zustand/middleware' import { immer } from 'zustand/middleware/immer' const initialState = { count: 1, double: 2 } export const useCountStore = create( immer( combine(initialState, set => ({ actions: { increase: () => set(state => { state.count += 1 }), decrease: () => set(state => { state.count -= 1 }) } })) ) )
# 상태 구독 (subscribeWithSelector)
스토어 훅에서 subscribe
함수를 사용하면, 스토어의 모든 상태 변경을 구독(상태를 감지해 리스너(Listener)를 호출)합니다.
그리고 subscribe
함수의 반환을 호출하면, 구독을 해제할 수 있습니다.
123const listener = (newState, oldState) => {} const unsubscribe = useCountStore.subscribe(listener) unsubscribe() // 구독 해제
만약 모든 상태가 아닌 특정 상태 변경만 구독하려면, subscribeWithSelector
미들웨어를 사용합니다.
그러면 subscribe
함수는 선택자(Selector)와 리스너를 인수로 받을 수 있습니다.
선택자는 상태를 반환하는 함수를 말하며, 반환 값을 통해 어떤 상태를 구독할지 결정합니다.
1234const selector = state => state.구독할상태 const listener = (newValue, oldValue) => {} const unsubscribe = useCountStore.subscribe(selector, listener) unsubscribe() // 구독 해제
다음 예제는 count
상태를 구독해, count
상태가 변경될 때마다 double
상태를 변경하는 예제입니다.
일종의 계산된 상태(Computed State)를 관리할 수 있습니다.
123456789101112131415161718192021222324252627282930313233343536import { create } from 'zustand' import { combine, subscribeWithSelector } from 'zustand/middleware' import { immer } from 'zustand/middleware/immer' const initialState = { count: 1, double: 2 } export const useCountStore = create( subscribeWithSelector( immer( combine(initialState, set => ({ actions: { increase: () => set(state => { state.count += 1 }), decrease: () => set(state => { state.count -= 1 }) } })) ) ) ) // double getter useCountStore.subscribe( state => state.count, // Selector count => { // Listener console.log(useCountStore.getState().double) // Getter useCountStore.setState(() => ({ double: count * 2 })) // Setter } )
만약 단일 컴포넌트에서만 구독을 사용하는 경우, 다음과 같이 useEffect
훅을 사용해 구독을 시작하고 컴포넌트가 언마운트될 때 구독을 해제할 수 있습니다.
123456789101112131415161718192021222324252627282930313233import { useEffect, useState } from 'react' import { useCountStore } from './store/count' export default function App() { const [double, setDouble] = useState(2) // 컴포넌트 언마운트 시 구독 해제 useEffect(() => { const unsubscribe = useCountStore.subscribe( state => state.count, count => { setDouble(count * 2) } ) return () => { unsubscribe() } }, []) // 컴포넌트 언마운트 시 구독 해제 (축약형) useEffect( () => useCountStore.subscribe( state => state.count, count => { setDouble(count * 2) } ), [] ) return <h2>{double}</h2> }
# 스토리지 사용 (Persist)
Zustand는 persist
미들웨어를 사용해 스토리지에 상태를 저장하고 불러올 수 있습니다.
이를 통해 페이지를 새로고침하거나 다시 방문했을 때에도 상태를 유지할 수 있습니다.
스토리지에 저장될 스토어의 고유한 이름을 필수 옵션(name
)으로 제공해야 합니다.
또한 로컬 스토리지(localStorage
)를 기본으로 사용하며, 필요하면 세션 스토리지(sessionStorage
)나 커스텀 스토리지를 만들어 사용할 수도 있습니다.
기타 옵션은 Zustand Persist Middleware(스토리지 사용)의 선택 옵션 정리에서 설명합니다.
123456789101112131415161718192021222324252627282930313233343536import { create } from 'zustand' import { combine, subscribeWithSelector, persist, createJSONStorage } from 'zustand/middleware' import { immer } from 'zustand/middleware/immer' const initialState = { count: 1, double: 2 } export const useCountStore = create( persist( subscribeWithSelector( immer( combine(initialState, set => ({ // actions: { // 스토리지(persist) 사용을 위해 제거! increase: () => set(state => { state.count += 1 }), decrease: () => set(state => { state.count -= 1 }) })) ) ), { name: 'countStore' // 스토리지에 저장될 고유한 이름! // storage: createJSONStorage(() => sessionStorage) // 기본값: localStorage } ) )
zoom_out_map
# 개발자 도구 (Devtools)
Redux DevTools과 같이 Zustand의 상태를 모니터링할 수 있는 개발자 도구를 사용할 수 있습니다.devtools
미들웨어를 사용하면, 개발자 도구가 활성화됩니다.
12345678910111213141516171819202122232425262728293031323334import { create } from 'zustand' import { combine, subscribeWithSelector, persist, devtools } from 'zustand/middleware' import { immer } from 'zustand/middleware/immer' const initialState = { count: 1, double: 2 } export const useCountStore = create( devtools( persist( subscribeWithSelector( immer( combine(initialState, set => ({ increase: () => set(state => { state.count += 1 }), decrease: () => set(state => { state.count -= 1 }) })) ) ), { name: 'countStore' } ) ) )
zoom_out_map
끝까지 읽어주셔서 감사합니다.
좋아요와 응원 댓글은 블로그 운영에 큰 힘이 됩니다!