Zustand
에서 상태를 가져올 때는 구조 분해 할당 방식과 selector를 통해 일부 상태만 구독하는 방식이 있다. 구조 분해 할당으로 값을 가져오면 어떤 상태가 변경되어도 리렌더링되는데, selector를 사용하면 구독하는 상태만 변경될 때 리렌더링된다. 어떻게 이게 가능한걸까?
Zustand의 사용법에 대해 간단히 정리하고, Zustand의 동작 원리에 대해 공부해보았다.
목차
Zustand란?
ㅤStore
ㅤ상태를 사용할 컴포넌트
ㅤ상태 업데이트는 어떻게 이루어질까?Zustand 코드 살펴보기
ㅤ1. Store를 생성하는 create
ㅤ2. 상태 관리의 기본 매커니즘 createStore
ㅤ3. 리액트 컴포넌트에서 상태에 접근할 수 있게 해주는 useStore원하는 값만 구독하는 selector
ㅤ1. 구조 분해 할당
ㅤ2. selector
ㅤ컴포넌트 리렌더링은 어떻게 가능할까?다른 상태 관리와 무엇이 다를까?
ㅤRedux vs Zustand
ㅤContext API vs Zustand
Zustand
는 독일어로 상태를 의미하고, 리액트 애플리케이션을 위한 작고 빠른 상태 관리 라이브러리이다.
Redux, MobX와 같은 다른 상태 관리 라이브러리들보다 더 간단하고 직관적인 API를 제공한다.
예시를 통해 사용법을 알아보자.
import create from 'zustand';
export const useCountStore = create((set) => {
// 상태
count: 0,
// 액션
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 }))
}));
발행-구독(publish-subscribe) 패턴
을 사용하여 상태 변화를 감지한다.import { useCountStore } from './useCountStore';
function Counter() {
const { count, increment, decrement } = useCountStore();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
</div>
)
}
Zustand는 필요한 상태만 구독해서 불필요한 리렌더링을 방지할 수 있다. 이 내용은 뒤에서 다룰 예정이다.
그림을 보면 컴포넌트들이 상태를 구독하고, 스토어에 정의된 액션을 호출할 수 있다. 액션을 호출하면 액션은 set 함수를 호출해 상태를 업데이트하고, 상태가 업데이트되면 구독 중인 컴포넌트에게 알려준다.
create가 스토어를 생성하고 createStore에서는 상태 관리, useStore를 통해서 리액트 컴포넌트와 원활하게 통신한다.
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
const api = createStore(createState)
const useBoundStore: any = (selector?: any) => useStore(api, selector)
Object.assign(useBoundStore, api)
return useBoundStore
}
create 함수는 Zustand 스토어를 생성하는 시작점이다.
create((set) => { 상태 정의, 액션 정의 })
로 사용하는데, (set) => { 상태 정의, 액션 정의 }
이 부분이 createState 함수인 것이다.📌 create 함수가 반환하는 useBoundStore
useBoundStore는 컴포넌트에서 상태를 구독할 수 있는 훅이다. getState, setState 같은 스토어 API 메소드들을 함께 가지고 있다.
const createStoreImpl: CreateStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>
type Listener = (state: TState, prevState: TState) => void
let state: TState // 상태를 저장할 변수
const listeners: Set<Listener> = new Set() // 상태 변화를 감지할 구독자를 저장하는 Set
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
if (!Object.is(nextState, state)) {
const previousState = state
state =
(replace ?? (typeof nextState !== 'object' || nextState === null))
? (nextState as TState)
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
const getState: StoreApi<TState>['getState'] = () => state
const getInitialState: StoreApi<TState>['getInitialState'] = () =>
initialState
const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
listeners.add(listener)
// Unsubscribe
return () => listeners.delete(listener)
}
const api = { setState, getState, getInitialState, subscribe }
const initialState = (state = createState(setState, getState, api))
return api as any
}
state
는 실제 상태 데이터를 저장한다.listeners
는 상태 변화를 구독하는 함수의 집합이다. 📌 상태와 listeners는 createStore에 저장되고 클로저에 의해 값이 유지된다.
- 상태는 모듈 레벨에서 단 하나만 존재한다.
- 클로저를 통해 상태를 안전하게 보관한다.
- setState, getState로만 상태에 접근할 수 있다.
- 컴포넌트들은 useStore를 통해 상태를 구독하고 접근한다.
구독하는 subscribe는 언제 호출될까?
subscribe는 구독 함수로 실제로 구독은 리액트의useSyncExternalStore
를 통해 이루어진다.
예를 들어 const count = useCountStore(state => state.count)를 호출하면, useSyncExternalStore가 subscribe를 호출해서 해당 함수가 리스너를 등록한다. 구독할 때는 컴포넌트를 리렌더링할 함수를 리스너로 전달하고, 컴포넌트가 언마운트될 때 자동으로 구독 해제한다.
setState
는 상태를 변경하는 함수이다.
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
// 새로운 상태 계산
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
// 상태가 실제로 변경되었는지 확인
if (!Object.is(nextState, state)) {
const previousState = state
// 상태 업데이트
state =
(replace ?? (typeof nextState !== 'object' || nextState === null))
? (nextState as TState)
: Object.assign({}, state, nextState)
// 상태가 변경되면 모든 구독자에게 알림
listeners.forEach((listener) => listener(state, previousState))
}
}
Object.is로 값을 비교하는 이유는 무엇일까?
Object.is로 값을 비교하면 정확한 값 비교가 가능하기 때문이다.
자바스크립트에서는 == 연산자와 === 연산자, Object.is로 값을 비교할 수 있다.
==
는 타입 변환을 하지만Object.is
는 타입 변환을 하지 않는다.Object.is
는 특수한 숫자 값은 수학적으로 올바르게 처리한다.===
연산자가 0과 -0의 차이를 인식하지 못한다면Object.is
는 이 차이를 인식한다.===
연산자로는 NaN를 제대로 비교할 수 없는데Object.is
는 올바르게 비교한다.
export function useStore<TState, StateSlice>(
api: ReadonlyStoreApi<TState>,
selector: (state: TState) => StateSlice = identity as any,
) {
const slice = React.useSyncExternalStore(
api.subscribe,
// 현재 상태에서 필요한 부분만 선택하는 함수
() => selector(api.getState()),
// 서버 사이드 렌더링을 위한 초기 상태
() => selector(api.getInitialState()),
)
React.useDebugValue(slice)
return slice
}
밑의 코드는 create 함수를 호출하면 반환하는 useBoundStore이다.
useBoundStore
는 컴포넌트에서 사용하게 될 커스텀 훅으로, selector를 선택적으로 받을 수 있으며, useStore를 호출하며 상태 값을 반환한다.
const useBoundStore: any = (selector?: any) => useStore(api, selector)
const useCountStore = create((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 }))
}));
useCountStore 사용할 때는 2가지 방법
이 있다.
첫번째 방법인 경우에는 selector를 전달하지 않기 때문에 기본적으로 selector의 매개변수는 state => state인 기본 함수가 전달된다.
두번째 방법은 selector를 전달하기 때문에 state => state.count가 전달된다.
selector를 전달하지 않고 useCountStore를 사용하는 경우에는 전체 상태가 slice에 담긴다. 이때는 구조 분해 할당으로 원하는 값을 가져올 수 있다.
반면 selector를 전달하면 selector가 반환하는 특정 값만 slice에 담기게 되고, 해당 값만 구독하게 된다.
selector를 전달하지 않고 구조 분해 할당
으로 스토어에서 값을 가져오게 되면 어떻게 될까?
// 컴포넌트 코드
const { count } = useCountStore();
// 내부 코드
const slice = useSyncExternalStore(
subscribe, // 구독 설정
() => selector(getState()),
() => selector(getInitialState())
);
전체 상태 객체
를 받게된다.상태가 변경될 때 다음과 같이 변한다.
// 초기 상태
const initialState = {
count: 0,
...
};
// count만 변경되고 나머지는 동일
const newState = {
count: 1,
...
};
selector
를 사용하면 구조 분해 할당으로 사용할 때와 무엇이 다를까?
// 컴포넌트 코드
const count = useStore(state => state.count);
// 내부 코드
const selector = state => state.count;
const slice = useSyncExternalStore(
subscribe,
() => selector(getState()),
() => selector(getInitialState())
);
상태가 변경될 때는 다음과 같이 변경된다.
// 초기 상태
const initialState = {
count: 0,
name: "채멈"
};
// case 1: count가 변경된 경우
const newState1 = {
count: 1, // 변경됨
name: "채멈"
};
// case 2: count가 아닌 다른 변수가 변경된 경우
const newState2 = {
count: 0,
name: "드뮴"
};
case1
은 name은 그대로이고 count만 변경된 상황이다.case2
는 count는 그대로이고 name만 변경된 상황이다.구조 분해 할당은 전체 상태 객체를 반환하여 객체 참조를 비교하기 때문에 하나의 상태라도 변경되면, 새로운 객체를 반환하기 때문에 참조가 달라진다. 따라서 이렇게 구독하게 되면 하나의 상태라도 변경되면 모든 구독 컴포넌트는 상태가 변경되었다고 판단해 컴포넌트가 리렌더링된다.
selector를 이용하면 특정 값만 추출해 값 자체를 비교하기 때문에 구독하는 값만 변경되었는지 확인한다. 따라서 특정 값만 구독하는 컴포넌트들은 구독하는 값 외의 다른 값이 변경되더라도 리렌더링되지 않고,
특정 값을 구독한다면 해당 값이 변경될 때만 리렌더링
이 발생한다.
setState 함수에서 listeners.forEach((listener) => listener(state, previousState));
코드는 상태가 변경되었을 때 상태를 구독하는 컴포넌트에게 알려주는 코드였다.
useSyncExternalStore 내부 동작을 간단하게 표현한 코드
function useSyncExternalStore(subscribe, getSnapshot) {
// 컴포넌트를 강제로 리렌더링하기 위한 함수
const [_, forceUpdate] = useState({});
useEffect(() => {
// listener 함수가 생성
function listener() {
// 1. 새로운 상태 가져오기
const nextSnapshot = getSnapshot();
// 2. 이전 상태와 비교
if (!Object.is(currentSnapshot, nextSnapshot)) {
// 3. 변경이 있다면 컴포넌트 리렌더링
forceUpdate({});
}
}
// listener를 구독 시스템에 등록
return subscribe(listener);
}, []);
}
listener 함수
는 상태 변경을 감지하고 필요한 경우 컴포넌트를 리렌더링하도록 만들어진 특별한 함수다. useSyncExternalStore에 의해 자동으로 생성되고 관리된다.
listener 함수
가 생성된다.Redux
// 1. 액션 타입
const INCREMENT = 'counter/increment';
// 2. 액션 생성자
const increment = () => ({ type: INCREMENT });
// 3. 리듀서
const counterReducer = (state = { count: 0 }, action) => {
switch (action.type) {
case INCREMENT:
return { ...state, count: state.count + 1 };
default:
return state;
}
};
// 4. 스토어 생성 및 Provider 설정
const store = createStore(counterReducer);
// 컴포넌트에서 사용
function Counter() {
const count = useSelector(state => state.count);
const dispatch = useDispatch();
return (
<div>
<p>{count}</p>
{/* dispatch를 통해 액션을 전달 */}
<button onClick={() => dispatch(increment())}>증가</button>
</div>
);
}
Zustand
const useCountStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
// 컴포넌트에서 사용
function Counter() {
const { count, increment } = useCountStore();
return (
<div>
<p>{count}</p>
<button onClick={increment}>증가</button>
</div>
);
}
Redux와 다르게 Zustand는 상태 변경을 직접한다.
Redux
는 Action → Dispatch → Reducer라는 과정을 거쳐야하지만,Zustand
는 상태 변경 함수를 직접 호출해서 바로 상태 업데이트를 한다.직접적으로 관리하기 때문에 코드가 더 간단하고 직관적이다. 그러나 상태 변경 추적과 디버깅이 Redux보다는 덜 체계적일 수 있다.
Context API
// 1. Context 생성
const CountContext = createContext();
// 2. Provider 컴포넌트 생성
const CountProvider = ({ children }) => {
const [count, setCount] = useState(0);
const increment = () => {
setCount(prevCount => prevCount + 1);
};
return (
<CountContext.Provider value={{ count, increment }}>
{children}
</CountContext.Provider>
);
};
// 3. 사용할 때마다 Provider로 감싸줌
function App() {
return (
<CountProvider>
<Component />
</CountProvider>
);
}
// 컴포넌트에서 사용
function Counter() {
const { count, increment } = useContext(CountContext);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>증가</button>
</div>
);
}
Zustand
const useCountStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
// 컴포넌트에서 사용
function Counter() {
const { count, increment } = useCountStore();
return (
<div>
<p>{count}</p>
<button onClick={increment}>증가</button>
</div>
);
}
Context API
는 주로 테마나 인증 상태와 같이 애플리케이션 전반에 걸쳐 자주 변경되지 않는 데이터를 공유할 때 적합하다.
Zustand는 리액트를 위한 작고 빠른 상태 관리 라이브러리로, 다른 라이브러리들보다 더 간단하고 직관적인 API를 제공한다.
핵심 구성 요소
create
: 스토어를 생성하는 함수createStore
: 클로저로 상태 관리useStore
: 리액트 컴포넌트와 원활하게 통신상태 구독 방식
상태 업데이트
set 함수
를 통해 이루어지고, 이 과정에서 Object.is
를 사용하여 상태 변경을 감지한다.다른 상태 관리 라이브러리와의 차이
Zustand는 독일어로 상태를 의미하며, 리액트 애플리케이션을 위한 작고 빠른 상태 관리 라이브러리입니다. Redux, MobX와 같은 다른 상태 관리 라이브러리들보다 더 간단하고 직관적인 https://www.cashnetusaus.com API를 제공합니다.
Zustand는 독일어로 상태를 의미하며, 리액트 애플리케이션을 위한 작고 빠른 상태 관리 라이브러리입니다. Redux, MobX와 같은 다른 상태 관리 라이브러리들보다 더 간단하고 직관적인 https://www.cashnetusaus.com API를 제공합니다.
Chill한 곰돌..