Redux 외에도 사람들이 많이 사용하는 상태 관리 라이브러리를 사용해보고 싶어서 Zustand 공식 문서를 읽어보고 사용해 봤다. Recoil과 Zustand 중에 고민했는데 Recoil은 페이스북에서 손을 놓은 것인지... 마지막 업데이트로부터 시간이 많이 지나 여러 사람들이 다른 라이브러리로 마이그레이션 하는 것을 보고 Zustand를 선택했다.
'Zustand'는 독일어로 '상태'라는 뜻을 가진다. 공식 페이지에서는 Zustand를 작고, 빠르고, 확장 가능한 기본적인 상태 관리 솔루션이라고 소개하고 있다. Zustand는 hooks를 기반으로 하는 사용하기 쉬운 API를 제공한다. 보일러플레이트 코드가 많지 않고 단순화된 Flux 원칙을 바탕으로 한다.
설치
npm i zustand
Store 생성
import { create } from 'zustand';
type CounterState = {
count: number;
}
type CounterActions = {
increment: (qty: number) => void;
decrement: (qty: number) => void;
};
export const useCountStore = create<CounterState & CounterActions>((set) => ({
count: 0,
increment: (qty: number) => set((state) => ({ count: state.count + qty })),
decrement: (qty: number) => set((state) => ({ count: state.count - qty })),
}));
정말 간단하다. 원시타입, 객체, 함수 무엇이든 넣을 수 있으며 set 함수가 state를 병합한다.
set 함수를 통해 state를 업데이트 할 수 있다.
또한 React에서 useState를 통해 상태를 불변하게 업데이트하는 것처럼 Zustand에서 상태를 업데이트할 때도 불변하게 업데이트해야 한다. 그럼 다음과 같이 작성을 해야 할 것이다.
set((state) => ({ ...state, count: state.count + 1 }))
그러나 set 함수는 state를 병합하기 때문에 ...state 부분을 건너뛸 수 있다.
set((state) => ({ count: state.count + 1 }))
set 함수는 한 단계만 병합하기 때문에 중첩된 객체의 경우 기존의 방식처럼 명시적으로 병합해줘야 한다.
import { create } from 'zustand'
const useCountStore = create((set) => ({
nested: { count: 0 },
inc: () =>
set((state) => ({
nested: { ...state.nested, count: state.nested.count + 1 },
})),
}))
Reset State
export const useCountStore = create<CounterState & CounterActions>((set) => ({
...initialCount,
increment: (qty: number) => set((state) => ({ count: state.count + qty })),
decrement: (qty: number) => set((state) => ({ count: state.count - qty })),
reset: () => set(initialCount)
}));
위에서 다룬 store 생성 예제를 Redux Toolkit으로 구현하면 다음과 같이 작성할 수 있다.
(counterSlice.ts)
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { RootState } from './store';
interface CountState {
value: number;
}
const initialState: CountState = {
value: 0,
};
const countSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
decrement: (state, action: PayloadAction<number>) => {
state.value -= action.payload;
},
},
});
export const { increment, decrement } = countSlice.actions;
export const count = (state: RootState) => state.counter.value;
export default countSlice.reducer;
(store.ts)
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: { counter: counterReducer },
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
한눈에 봐도 Zustand가 Redux에 비해 코드가 직관적이며 필요한 코드도 적다.
러닝 커브가 낮다는 걸 확 체감할 수 있었다.
컴포넌트에 바인딩
function Counter() {
const count = useCounterStore((state) => state.count);
return <h1>{count}</h1>;
}
function Controls() {
const increment = useStore((state) => state.increment);
return <button onClick={()=>increment(2)}>Increment</button>;
}
바인딩도 굉장히 쉽게 이루어진다. Redux나 Context API처럼 Provider로 감싸줄 필요도 없다.
애플리케이션 내에서 렌더링 최적화를 할 때, Zustand와 Redux 사이의 접근 방식에는 큰 차이가 없다. 두 라이브러리 모두 selector를 사용하여 렌더링 최적화를 수동으로 적용하는 것을 권장한다.
여기서 selector를 사용하는 것과 사용하지 않는 것의 차이는 무엇인지 궁금해서 찾아봤다.
// 1. selector를 사용하지 않고 디스트럭처링 문법만을 사용
const { count, increment } = useCounterStore();
// 2. selector를 사용
const { count, increment } = useCounterStore((state) => ({
count: state.count,
increment: state.increment,
}));
첫 번째 방법을 사용하면 상태 객체의 일부 속성이 변경될 때마다 컴포넌트가 다시 렌더링 될 가능성이 높다. 예를 들어, 상태 객체에 다른 속성이 추가되거나 변경되더라도, 그와 관계없이 이 컴포넌트는 다시 렌더링 될 수 있다.
두 번째 방법을 사용하면 선택자 함수로 필요한 속성만 구독하므로, 상태 객체의 다른 속성 변화에 영향을 받지 않는다. 이는 불필요한 렌더링을 방지하여 성능 최적화에 유리하다.
Zustand에서 권장하는 일부 규칙들
1. 전역 상태는 하나의 store에 위치해야 한다.
애플리케이션의 규모가 크다면 Zustand는 store 분할을 지원한다.
2. 상태 업데이트를 위해 항상 set (또는 setState)를 사용해야 한다.
set(또는 setState)는 설명된 업데이트가 올바르게 병합되고 리스너에게 적절한 알림이 전송되도록 한다.
3. Action을 store에 state와 함께 배치하자
Action을 store에서 분리할 수도 있다.
+ Reducer를 사용하는 것을 선호한다면 Redux처럼 작성할 수도 있다.
Selector 자동 생성 하기
매번 다음과 같이 selector를 사용하는 것이 귀찮다면 selector를 자동 생성할 수 있다.
const bears = useBearStore((state) => state.bears)
createSelectors 구현
import { StoreApi, UseBoundStore } from 'zustand'
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never
const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
_store: S,
) => {
let store = _store as WithSelectors<typeof _store>
store.use = {}
for (let k of Object.keys(store.getState())) {
;(store.use as any)[k] = () => store((s) => s[k as keyof typeof s])
}
return store
}
store에 생성한 함수 적용
interface BearState {
bears: number
increase: (by: number) => void
increment: () => void
}
const useBearStoreBase = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
increment: () => set((state) => ({ bears: state.bears + 1 })),
}))
Apply that function to your store:
// 추가!
const useBearStore = createSelectors(useBearStoreBase)
이제 다음과 같이 사용할 수 있다.
// get the property
const bears = useBearStore.use.bears()
// get the action
const increment = useBearStore.use.increment()
useShallow를 이용한 불필요한 리렌더링 방지하기
계산된 selector는 object.js에 따라 출력값이 변경된 경우 리렌더링된다.
이 경우 계산된 값이 항상 이전과 동일할 때 useShallow를 사용하여 리렌더링을 피할 수 있다.
예시로 다음과 같이 각 곰과 식사를 연관 짓는 스토어를 가지고 있으며, 그들의 이름을 렌더링하려는 경우를 살펴보자.
import { create } from 'zustand'
const useMeals = create(() => ({
papaBear: 'large porridge-pot',
mamaBear: 'middle-size porridge pot',
littleBear: 'A little, small, wee pot',
}))
export const BearNames = () => {
const names = useMeals((state) => Object.keys(state))
return <div>{names.join(', ')}</div>
}
다음과 같이 papaBear의 값이 변경되었을 때
useMeals.setState({
papaBear: 'a large pizza',
})
이 변경으로 인해 names의 실제 출력이 shallow equal(얕은 비교)에 따라 변경되지 않았음에도 불구하고 BearNames의 리렌더링이 발생한다.
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
(생략)
export const BearNames = () => {
const names = useMeals(useShallow((state) => Object.keys(state)))
return <div>{names.join(', ')}</div>
}
위와 같이 useShallow를 사용하면 불필요한 렌더링을 피할 수 있다.
Zustand를 처음 살펴보고 사용해 봤는데 정말 간단했고 공식 문서가 잘 되어있어 이해하기도 쉬웠다. 사람들이 왜 많이 사용하는지 알 것 같았다.. 다음엔 Zustand로 프로젝트를 해봐야겠다.
공식문서
https://docs.pmnd.rs/zustand/getting-started/introduction
'Develop > React' 카테고리의 다른 글
[React] Sentry, ErrorBoundary로 오류 대응하기 (0) | 2024.10.10 |
---|---|
[React] useRef, scrollIntoView를 통한 스크롤 위치 이동 (0) | 2024.09.23 |
[React] 라이브러리 없이 Toast 구현하기 (2) | 2023.11.02 |
[React] ios 환경에서 input, textarea 화면 확대 방지하기 (+ 웹 접근성) (0) | 2023.10.13 |
[React] useReducer (0) | 2023.09.11 |