기존의 프론트엔드 프로젝트를 새롭게 작성할 기회가 있어 상태관리 라이브러리들을 한 번 살펴보던 중 zustand가 눈에 들어왔고, zustand docs 기준으로 사용법을 한 번 정리해보았다.
단순화한 flux 패턴을 이용한 작고 빠르고 확장 가능한 상태 관리 솔루션
zustand의 소개 페이지의 첫 문장이 이렇게 시작한다. flux 패턴은 redux를 사용한 개발자라면 많이 들어보았을텐데 단방향으로 데이터를 관리할 수 있는 패턴이다.
flux 패턴과 비슷하게 작성하지만 확실히 코드량이 많이 줄어들었다. 특히 redux-toolkit과 비슷하다는 느낌을 많이 받았는데 redux-toolkit에서는 slice를 생성할 때 name, initialState, reducers처럼 정해진 파라미터를 전달해야 하는 것과는 달리, state와 action을 하나의 store에서 생성할 수 있고 또 생성할 때의 제약 또한 없어서 훨씬 간단하고 자유도가 높았다고 느꼈다.
개발진이 같아서인지 zustand와 jotai의 궁합도 잘 맞는 것 같고 문서에서도 함께 사용할 수 있는 방법을 설명해놓았다.
import create from "zustand";
const useBearStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}));
function BearCounter() {
const bears = useBearStore((state) => state.bears);
return <h1>{bears} around here ...</h1>;
}
function Controls() {
const increasePopulation = useBearStore((state) => state.increasePopulation);
return <button onClick={increasePopulation}>one up</button>;
}
사용법은 정말 간단하다. create 함수로 상태와 액션을 함께 정의해주고 해당 store를 호출해 사용하면 된다. 따로 store별 name을 지정할 필요도 없다.
상태를 변경할 때는 set 함수를 호출해 변경할 상태를 넘겨주면 된다.
1)
const state = useBearStore()
2)
const nuts = useBearStore((state) => state.nuts)
const honey = useBearStore((state) => state.honey)
내가 생각했던 것과 가장 다르게 동작했던 부분이 여기였다. 분명 렌더링하고 있는 상태에 어떤 변화도 없었는데 다른 상태가 변할때에도 해당 컴포넌트가 계속해서 리렌더링 되는 것이었다. 이 부분의 문서를 제대로 읽었어야 했는데 껄껄.
zustand에서는 기본적으로 strict-equality(old === new)
로 상태의 변화 여부를 판단한다. 따라서 1과 같이 store에 접근할 경우 A 컴포넌트에서 a라는 상태만 렌더링을 하고 있지만, b 상태에 변화가 일어날 경우 strict equality 비교로 인해 state가 변화가 일어났다고 판단하고 A 컴포넌트 또한 렌더링이 된다. 그래서 2와 같이 직접 사용할 상태만을 리턴해주도록 사용해야 한다.
import shallow from "zustand/shallow";
// Object pick, re-renders the component when either state.nuts or state.honey change
const { nuts, honey } = useBearStore(
(state) => ({ nuts: state.nuts, honey: state.honey }),
shallow,
);
// Array pick, re-renders the component when either state.nuts or state.honey change
const [nuts, honey] = useBearStore(
(state) => [state.nuts, state.honey],
shallow,
);
// Mapped picks, re-renders the component when state.treats changes in order, count or keys
const treats = useBearStore((state) => Object.keys(state.treats), shallow);
만약, store 안의 여러 개의 상태에 접근하고 싶은데 리렌더링을 피하고 싶다면 store 함수를 호출할 때 2번째 인자로 equality function을 넘겨주면 된다. zustand에서 지원해주는 shallow 함수도 있는데 이를 넘겨줘도 가능하다.
import create from 'zustand'
const useCountStore = create((set) => ({
count: 0,
// 1)
inc: () => set((state) => ({ ...state, cuont: state.count + 1 }))
// 2)
inc: () => set((state) => ({ count: state.count + 1 })),
}))
리액트의 useState
와 같이 zustand에서는 상태를 불변적으로 업데이트 해야 한다. 따라서 깊이 중첩된 객체를 수정해야 하는 경우 immer와 같은 라이브러리를 통해 상태를 업데이트 해주면 편하다.
또한, 제공되는 set 함수를 사용할 경우 이미 존재하는 상태와 얕게 merge하기 때문에 1번처럼 state를 spread operator를 통해 사용해 merge 하지 않아도 된다. 즉, 2번처럼 사용해도 초기에 정의해놓은 count나 inc 함수는 자동으로 merge가 된다.
const useFishStore = create((set) => ({
salmon: 1,
tuna: 2,
deleteEverything: () => set({}, true), // clears the entire store, actions included
}));
action 함수 생성하며 set 함수를 호출하게 되는데 두 번째 인자로 true를 넘겨주면 해당 store의 모든 상태와 액션들이 모두 제거된다.
const useSoundStore = create((set, get) => ({
sound: "grunt",
action: () => {
const sound = get().sound
// ...
}
})
action 함수에서 상태를 읽기 위해선 get 함수를 사용하면 된다.
const useDogStore = create(() => ({ paw: true, snout: true, fur: true }));
// Getting non-reactive fresh state
const paw = useDogStore.getState().paw;
// Listening to all changes, fires synchronously on every change
const unsub1 = useDogStore.subscribe(console.log);
// Updating state, will trigger listeners
useDogStore.setState({ paw: false });
// Unsubscribe listeners
unsub1();
// Destroying the store (removing all listeners)
useDogStore.destroy();
zustand는 리액트 컴포넌트 내부가 아닌, 외부 저장소를 이용하기 때문에 컴포넌트 외부에서 상태를 읽고 쓸 수 있다. 하지만 컴포넌트 내부에서 사용할 때처럼 상태가 변할 때마다 바로 반영이 되지는 않기 때문에 바로 상태의 변화를 알기 위해서는 subscribe 과정을 따로 거쳐야 한다.
import create from 'zustand'
const useCountStore = create((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
})
// 1)
set((state) => ({ state, count: state.count + 1 }))
// 2)
set((state) => ({ ...state, count: state.count + 1 }))
set((state) => newState, true);
set 함수에서 merge를 막기 위해서는, 두 번째 인자로 true를 전달하면 된다.
const useStore = create((set) => ({
salmon: 1,
tuna: 2,
deleteEverything: () => set({}, true), // clears the entire store, actions included
}));
set 함수의 두 번째 인자 기본값은 false인데 true를 넘기면 해당 store의 상태를 모두 지워준다. (action까지!)
Single Store?
https://github.com/pmndrs/zustand/blob/main/docs/guides/slices-pattern.md
기존에 recoil을 사용했다보니 recoil의 사용법에 익숙해 좀 헷갈렸던 부분들이다.
// destructuring
const { value } = useStore((state) => ({
value: state.value,
}));
// selector 적용
const value = useStore((state) => state.value);
여러 상태 슬라이스 하기
https://github.com/pmndrs/zustand/discussions/999
const useStore = create<{
a: null | number;
b: () => boolean;
addA: (value: number) => void;
removeA: () => void;
}>((set, get) => ({
a: null,
// a값을 가져와 연산된 값을 리턴함
// 당연히, a가 바뀌었을 때 b도 재실행 되면서 바뀌어야 할 것 같았다.
b: () => (get().a ? true : false),
addA: (value) =>
set({
a: value,
}),
removeA: () =>
set({
a: null,
}),
}));
zustand 자체가 리액트 내부가 아닌, 외부에서 상태 관리를 하는, 리액트에 의존적인 라이브러리가 아니다보니 리액트 상태관리 도구와는 약간 다른 점이 있는 것 같기도 하다.
특히 의아했던 점은 get() 함수이다. 당연히 A 상태가 변했을 때 get 함수로 A 상태를 가져와 연산을 거쳐 값을 리턴해주는 B 함수는 A 상태가 변했을 때 B 상태도 변경되는줄 알았는데 아니었다. 그냥 초기에 값을 가져올 때만 유용한 것 같은데, 상태가 계속해서 변할 수 있는 가능성이 충분히 있는데 왜 지원이 안되는지 의문이다.
→ get 함수를 set 함수와 함께 사용하거나, react와 관련이 없는 side effect에서 유용하다. 즉, 저 동작이 애초에 의도했던 동작이라는 것이다.
https://github.com/pmndrs/zustand/discussions/774#discussioncomment-2102067
출처)