🐻 Zustand 배우기
Zustand 라이브러리에 대해서 활용을 해볼 것이다.
리덕스 툴킷을 활용해서 쇼핑카트를 UI부터 끝까지 만들어 봤다.
보일러 플레이트나 라이브러리가 좀 가벼운 zustand를 많이 활용해보는 것이 좋다.
zustand는 전역 상태관리에서 많이 사용하는 라이브러리이다.
프로젝트 생성
pnpm create vite
zustand 다운로드
pnpm i zustand
zustand로 관리
import './App.css';
import Counter from './components/Counter';
import RandomNumberGenerator from './components/RandomNumberGenerator';
function App() {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 100,
}}
>
<Counter />
<RandomNumberGenerator />
</div>
)
}
export default App;
store를 구현해본다.
zustand 사용법은 정말 보일러 플레이트가 간단하다.
create를 임포트. 상태에 대한 정의를 해준다.
항상 불변성을 지켜 줘야 하기 때문에 이를 신경을 써야 하는데 zustand는 감사하게도 값을 쉽게 바꿀 수 있는 방법을 제공해준다. -> set 사용.
zustand 같은 경우는 일단 automic selector 그래서 각 상태는 가능한 독립적인 구독 단위로 관리한다.
개별 구독하는 것이 중요하다.
객체로 받고 싶은데 에러가 나는 이유는 store의 셀렉터가 불변성을 지키지 않거나 매번 새로운 객체를 반환할 때 발생한다.
셀렉터가 매번 새로운 객체를 반환하므로 리액트는 그 값이 바뀌었으니까 착각을 하게 된다. 그러면 값이 바뀌었다는 것은 리렌더링이 돼야 하니까 계속 무한 루프가 나선형처럼 계속 돈다고 생각을 하면 된다.
근데 이거를 객체로 뽑고 싶을 것인데 이를 뽑는 방법 하나가 있다. state 안에 state 이런 식으로 관리를 하면 문제가 안 되긴 한다.
특정한 것을 바꿔도 다른 쪽의 리렌더링에도 영향을 받는다.
-> 이를 해결하기 위해서 useShallow를 사용한다.
useShallow는 얕은 비교를 한다고 생각하면 된다. 개별 구독하는 것을 더 추천. 객체를 뽑아야 한다면 이런 방법이 있다라고 생각하자.
import { useCounterStore } from "../stores/counterStore"
export default function Counter() {
const { count, increment, decrement } = useCounterStore((state) => ({
count: state.count,
increment: state.increment,
decrement: state.decrement,
}));
return (
<div>
<h1>{count}</h1>
<button onClick={increment}>증가</button>
<button onClick={decrement}>감소</button>
</div>
);
};
shallow를 이용하여 선언하면,
const { count, increment, decrement } = useCounterStore(
useShallow((state) => ({
count: state.count,
increment: state.increment,
decrement: state.decrement,
}))
);
이런 식으로 state를 뽑아서 처리해줄 수 있다. 그럼 count의 값이 변경이 되는 것은 즉, 랜덤 번호가 바뀌는 것은 다른 것과 상관이 없어진다. 오직 랜덤 번호 생성만 하게 됨. 증가/감소는 아마 랜덤 번호 생성기도 다 구독하고 있다. 그래서 랜덤 번호 생성기도 shallow 처리해준다.
import { useShallow } from "zustand/shallow";
import { useCounterStore } from "../stores/counterStore";
export default function RandomNumberGenerator() {
const { randomNumber, random } = useCounterStore(
useShallow((state) => ({
randomNumber: state.randomNumber,
random: state.random,
}))
); // 개별 구독
return (
<div>
<h1>{randomNumber}</h1>
<button onClick={random}>랜덤 번호 생성기</button>
</div>
)
}
이런 식으로 처리해주면 되고,
만약에 이렇게 처리하기 싫다면 개별 구독 패턴을 활요하면 된다.
// 개별 구독
const randomNumber = useCounterStore((state) => state.randomNumber);
const random = useCounterStore((state) => state.random);
코드를 짤 때 유의할 점은 리덕스 같은 것에서는 이것도 결국에는 리덕스를 기반으로 만들어진 리덕스랑 비슷한 것이므로 이런 것들에 대해서 구분을 하는 것이 좋다. - 액션 단위 구분하는 것이 중요.
액션 단위로 구분하는 것은
액션 분리
import { create } from 'zustand';
interface CounterActions {
increment: () => void;
decrement: () => void;
random: () => void;
}
// 상태에 대한 정의
interface CounterState {
// count
// => value
count: number;
// increment: () => void;
// decrement: () => void;
// randomNumber
randomNumber: number;
// random(): void;
// action
actions: CounterActions;
};
export const useCounterStore = create<CounterState>((set) => ({
// inital state를 설정해야 함.
count: 0,
randomNumber: 0,
actions: {
increment: () =>
set((state) => ({
count: state.count + 1,
})),
decrement: () =>
set((state) => ({
count: state.count - 1
})),
random: () =>
set(() => ({
randomNumber: Math.floor(Math.random() * 100)
})),
}
}));
이에 맞게 변경한다. -> state.actions.~~
이런 식으로 깔끔하게 분리하면 테스트 코드나 기타 작업을 한다고 했을 때 actions의 객체에 대해서 한 번만 생성을 하고 싶을 때 automic으로 분리를 해야 에러가 발생하지 않는다.
그리고 counterStore에서 액션에 관한 훅을 하나 만들 수 있는데 그 말은 액션에 관련된 내용들만 따로 뽑아낼 수 있다는 것이다. 액션에 대해서 직접 선언하는 방식도 있긴 하지만 액션이 많아지면 코드 양이 길어질 것이다. 그리고 무한히 렌더링 되는 이유가 뭐였냐면 객체에 대한 비교와 관련된 것인데 예를 들어, actions 객체를 그냥 store에서 따로 하나를 만들고 생성을 하면 좋은 점은 참조의 안전성을 가질 수 있다.
이렇게 액션에 관한 훅을 하나 빼주면 액션을 쉽게 관리할 수 있다.
// actions에 관한 훅을 하나 만들 수 있다.
export const useCounterActions = () =>
useCounterStore((state) => state.actions);
// 이 객체만 구독한다.
reduxDevtools - 액션들을 관리할 수 있다.
이것을 함으로써 미들웨어 체이닝이 가능하다.
export const useCounterStore = create<CounterState>()(
devtools((set) => ({
// inital state를 설정해야 함.
count: 0,
randomNumber: 0,
actions: {
increment: () =>
set((state) => ({
count: state.count + 1,
})),
decrement: () => {
return set((state) => ({
count: state.count - 1
}));
},
random: () => {
set(() => ({
randomNumber: Math.floor(Math.random() * 100),
}));
},
},
}))
);
이런 식으로 해주면 어떤 액션을 취하고 있는지 확인할 수 있다.
여기서 문제는 anonymous라고 뜨는 것인데,
현재는 한 스토어만 관리하는 데 많은 스토어들을 관리한다고 하면 스토어에 대한 이름을 명시해 주는 것이 좋다.
여기서 false가 default 값인데 이전 상태가 잃어버린다 or 안 잃어버린다에 대해 보여 주기 좋은 예시다.
감소를 누르면 값들이 액션들을 다 기억하고 있을 것이다. 근데 증가를 누르면 다 사라질 것이다. count만 남음. 나머지 것들을 다 잃어버리는 상황이다.
zustand로 이전 값을 기억할 수 있는 이유는 shouldReplace가 기본값 즉 false라서 그렇다고 이해하면 될 것이다. -> 이에 대해서는 undefined로 처리해준다. (boolean 값으로 건드리지 않으려면.)
추가적으로,
immer나 persist 미들웨어도 활용해보는 것도 좋다.