컨셉
zustand는 redux, zotai, valtio, recoil 등과 더불어 대표적인 상태 관리 라이브러리 중 하나다.
zustand의 특징은 다음과 같다
- flux 디자인 기반 - 중앙 집중식 상태 관리 | redux와 유사하게 state를 단일 store에서 관리하며 dispatcher는 중앙에서 정의된 메소드를 사용한다.
- 선택적 reducer | redux는 모든 dispatch 요청을 reducer 함수 내에서 action.type에 따라 분기처리하는데 반해, zustand는 선택 사항에 따라 그러지 않을 수 있다. 대신, 여러 개의 메소드 중 하나를 선택하여 dispatch 한다.
- 불변 상태 관리 | redux처럼 불변식 상태관리 방식을 사용하며, 복잡한 상태 관리에는 immer과 조합이 좋다.
- 간단한 설정 | redux는 다양한 설정을 제공하지만, 이것이 앱의 초기 세팅에는 보일러 플레이트로 상당히 번거롭다. 하지만, zustand는 provider이 필요하지 않고 store 설정이 간단하다. 그러면서 slice 기반의 결합형 store 처럼 redux와 유사한 기능을 제공한다.
- selector 기반 렌더링 최적화 | redux와 유사하게, 중앙에서 관리하는 거대한 store을 각 컴포넌트에서 일부분만 복사해 사용하는 selector 방식을 쓴다.(redux에서는 useSelector라 부르고, zustand에서는 별도의 훅이 없이 자동으로 제공되지만, 타입 선언에 따라 useBoundStore라 부를 수 있다) 두 라이브러리 모두 store가 변경되면 각 컴포넌트에서 참조하는 selector는 기본 비교 알고리즘에 따라 변경 여부를 체크하며, selector가 객체 형식인 경우 이것이 제대로 작동하지 않으므로 shallow 비교 로직이나 사용자 정의 비교 함수를 제공할 수 있다.
- recoil 대비 가벼운 용량 | react-redux보다 가벼우며 recoil 용량의 8분에 1 수준, @reduxjs/toolkit의 10분에 1이상 가볍다.
다른 라이브러리와의 비교
redux와의 비교
- 기본 구조와 동작 방식에는 별 차이가 없는 "중앙 집중식 불변 상태 관리 - selector 최적화 방식"이다.
- redux에 비해 보일러 플레이트 설정이 적고 provider로 루트 컴포넌트를 감쌀 필요가 없다.
- 또한, flux 디자인 패턴(reducer, dispather 방식)에서 비교적 자유롭다.
valtio와의 비교
- valtio는 가변 상태 모델로, 속성 값을 바로 변경할 수 있다. 이는 항상 새로운 객체를 생성하기 위해 구조 분해 생성자를 사용하는 불변 상태 모델과 비교된다.
jotai, recoil와의 비교
- jotai, recoil은 atom이라 부르는 state를 분산하여 관리하는 분산식 상태 관리 구조이며, atom을 합쳐 복합적인 state를 생성하는 상향식이다. 반면, zustand는 중앙식 상태 관리 구조에 selector을 이용하는 하향식이다.
- zustand는 redux와 유사하게 devtool을 사용하여 디버깅에 용이하다.
기본 사용법
1) store 생성
store란, 프로젝트 내에 사용되는 글로벌 상태를 모두 저장하는 단일 상태 객체이다.
다음처럼 create 함수를 사용해서 내부에 store 객체를 생성한다.
store의 구성은 상태 값과 상태 업데이트 함수(setter)로 이뤄진다.
import { create } from "zustand";
const useMainStore = create((set) => ({
// 상태 값
counter: {
bear: 0,
rabbit: 0,
},
// setter 함수
increase: ({ animal }) =>
set((state) => {
return {
counter: {
...state.counter,
[animal]: state.counter[animal] + 1,
},
};
}),
}));
export default useMainStore;
2) state 사용하기
위에서 다룬 create 함수는 useBoundStore를 반환하는데, 이는 redux의 useSelector처럼 store의 상태 일부를 선택하는 기능을 한다. 선택한 부분에 대해서 컴포넌트는 자동으로 구독 설정된다.
import React from "react";
import useMainStore from "../stores/useMainStore";
export default function Counter1() {
const bearCount = useMainStore((state) => state.counter.bear);
const setBearCount = useMainStore((state) => state.increase);
return (
<div>
<h2>counter</h2>
<p>bear: {bearCount}</p>
<button onClick={() => setBearCount({ animal: "bear" })}>
plus bear
</button>
</div>
);
}
useBoundStore은 react hook이므로 일반적인 훅에 적용되는 규칙을 따라야 한다.
예컨대, 아래처럼 컴포넌트의 최상단이 아닌 곳에서 호출하면 에러가 발생한다.
그래서 훅 규칙을 까먹지 않기 위해 use를 접두사로 붙이자.
const setBearCount =()=> mainStore((state) => state.increase)({animal:"bear"});
3) state 업데이트 하기
중앙 집중식 상태 관리인 redux와 동일하게, 중앙의 store에서 선언된 dispatcher 함수를 전역에서 공유하여 사용한다.
하지만, 사용방식은 redux와 다르게 사용할 수도 있고, 동일하게 사용할 수도 있다.
dispatcher-function 모델
dispatcher 역할을 하는 함수가 곧 setState기능으로서 store을 바로 변경한다.
import { create } from "zustand";
const useMainStore = create((set) => ({
// 상태 값
counter: {
bear: 0,
rabbit: 0,
},
// setter 함수
increase: ({ animal }) =>
set((state) => {
return {
counter: {
...state.counter,
[animal]: state.counter[animal] + 1,
},
};
}),
}));
function App(){
const setBearCount = useMainStore((state) => state.increase);
return (
<button onClick={() => setBearCount({ animal: "bear" })}>
plus bear
</button>
)
}
dispatcher-reducer모델(리듀서 방식)
dispatcher 함수는 올바른 reducer로 state 변경 요청을 전달하기만 한다.
상태변경은 reducer 함수에서 state 변경 요청의 type에 따라 스위칭 분기 처리된다.
import { create } from "zustand";
const types = { bear: 'BEAR', rabbit: 'RABBIT' }
const reducer = (state, { type, amount }) => {
switch (type) {
case types.bear:
return { counter: {...state.counter, bear: state.counter.bear + amount} }
case types.rabbit:
return { counter: {...state.counter, rabbit: state.counter.rabbit + amount} }
}
}
const useMainStore = create((set) => ({
// 상태 값
counter: {
bear: 0,
rabbit: 0,
},
dispatcher:(args) set((state)=>reducer(state,arg)),
}));
function App(){
const dispatcher = useMainStore((state) => state.dispatcher);
return (
<button onClick={() => dispatcher({ type: "bear", amount:1 })}>
plus bear
</button>
)
}
주의사항: 얕은 병합 메커니즘(shallow merge) - zustand의 불변성 변경 원리
redux를 써 본 사람들은 알겠지만 거대한 상태 객체를 업데이트 하려면 변경되는 일부를 제외한 나머지 부분도 spread 연산자 등으로 동일하게 리턴해야 한다. 예컨대, 아래와 같은 store에서 foo를 5로 업데이트하려면 dispatcher를 다음같이 사용한다.
{foo:1, bar:2, baz:3}
dispatcher(state⇒({…state, foo:5,}))
하지만, zustand는 store객체의 1차 depth까지는 이 원칙을 적용하지 않아도 된다. 즉, 아래처럼 가능하다.
dispatcher(state⇒({foo:5,}))
이렇게 해도 bar과 baz는 무사하다.
즉, zustand는 store 객체에서 1차 깊이까지는 변경되는 속성을 제외한 나머지가 자동으로 기존 상태를 유지한다.
최적화
상태 관리 라이브러리에서 최적화란, 상태 변경을 구독한 컴포넌트가 불필요한 이유로 리렌더링되는 것을 막는 작업이다,
zustand같은 중앙 집중식 상태 관리 방식은 주로 select한 구독 상태가 "실제로 변경되었는지 여부"를 검사함으로써 최적화를 한다,
1) 자동 최적화
자동으로 적용되는 기본 최적화 메커니즘은 이전 상태와 현재 상태를 엄격한 동일성에 따라 비교하고 동일할 시 리렌더링되지 않는다.
예시1
app 컴포넌트 아래에 있는 counter1, countet2 컴포넌트는 각각 useMainStore.count.bear과 useMainStore.count.rabbit을 참조한다.(아래에서는 각각 bear과 rabbit이라 부르겠다)
bear과 rabbit은 count 객체 내부에 있으므로 둘 중 무엇을 바꾸든 dispather함수인 increase가 counter 객체 자체를 새로 반환하지만, counter1과 counter2 각각은 의존성이 실제로 변경될 때에만 재호출된다.
(counter1의 버튼을 눌러도 counter2는 재호출되지 않는다.)
그 이유는 상태 변경 시 bear과 rabbit은 이전 상태와 현재 상태를 (old===new) 형식으로 비교하기 때문이다.
//@app
function App() {
return (
<div className="App">
<Counter1 />
<Counter2 />
</div>
);
}
//@useMainStore
import { create } from "zustand";
const useMainStore = create((set) => ({
counter: {
bear: 0,
rabbit: 0,
},
increase: ({ animal }) =>
set((state) => {
return {
counter: {
...state.counter,
[animal]: state.counter[animal] + 1,
},
};
}),
}));
export default useMainStore;
//@counter1
function Counter1() {
console.log("counter1.렌더링");
const bearCount = useMainStore((state) => state.counter.bear);
const setBearCount = useMainStore((state) => state.increase);
return (
<div>
<h2>counter</h2>
<p>bear: {bearCount}</p>
<button onClick={() => setBearCount({ animal: "bear" })}>
plus bear
</button>
</div>
);
}
//@counter2
export default function Counter2() {
console.log("counter2.렌더링");
const rabbitCount = mainStore((state) => state.counter.rabbit);
const setRabbitCount = mainStore((state) => state.increase);
return (
<div>
<h2>counter</h2>
<p>rabbit: {rabbitCount}</p>
<button onClick={() => setRabbitCount({ animal: "rabbit" })}>
plus bear
</button>
</div>
);
}
const defaultEqualFn = (prevState, newState) => prevState === newState
// true를 반환하면 동일한 값으로 간주.
// false를 반환하면 다른 값으로 간주하여 리렌더링
2) 수동 최적화
이 엄격한 동일성 비교 알고리즘은 select한 useBoundStore가 객체인 경우에 문제가 생긴다. 왜냐하면 엄격한 동일성에 따라서 객체는 주소 값이 변경되면 내부 값이 동등해도 다른 걸로 간주되기 때문이다
예시
아래 코드는 CounterGoodBug와 CounterBadBug에서 각각 counter.bugs.ladybug와 {moskito: counter.bugs.moskito, bedbug : counter.bugs.bedbug }를 참조한다. 특히 CounterBadBug 컴포넌트 부분에서는 store를 참조하는 방식을 객체화하였다.
(이처럼 select 값을 관심사에 따라 그룹화하는 건 유지 보수에서 상당히 중요하다.)
이 경우, CounterGoodBug 컴포넌트에서 ladybug값을 변경하면, CounterBadBug 컴포넌트의 moskito와 bedbug는 값이 변경되지 않았음에도 엄격 동일성의 비교에 따라 달라진 걸로 간주되며 불필요하게 리렌더링된다.
const useMainStore = create((set) => ({
counter: {
bugs: {
ladybug: 0,
moskito: 0,
bedbug: 0,
},
},
increaseBug: ({ type }) =>
set((state) => {
if (type === "good") {
return {
counter: {
...state.counter,
bugs: {
...state.counter.bugs,
ladybug: state.counter.bugs.ladybug + 1,
},
},
};
}
if (type === "bad") {
return {
counter: {
...state.counter,
bugs: {
...state.counter.bugs,
moskito: state.counter.bugs.moskito + 1,
bedbug: state.counter.bugs.bedbug + 1,
},
},
};
}
}),
}));
//@counterGoodBug
export default function CounterGoodBug() {
console.log("counterGoodBug.렌더링");
// const equalFn= createWithEqualityFn()
const ladyBugCount = useMainStore(
(state) => state.counter.bugs.ladybug
// // equalFn
// (a, b) => {
// console.log(a, b);
// return a === b;
// }
);
const increaseGoodBug = useMainStore((state) => state.increaseBug);
return (
<div>
<h2>GoodBug</h2>
<p>ladyBug: {ladyBugCount}</p>
<button onClick={() => increaseGoodBug({ type: "good" })}>
plus bear
</button>
</div>
);
}
//@counterBadBug
export default function CounterBadBug() {
console.log("counterBadBug.렌더링");
const increaseBug = useMainStore((state) => state.increaseBug);
const { bedbug, moskito } = useMainStore(
(state) => ({
bedbug: state.counter.bugs.bedbug,
moskito: state.counter.bugs.moskito,
})
// 위 설명에 따르면, useBoundStore은 다음 같은 비교 함수가 기본적으로 작동한다
// (prevState, newState) => {
// console.log(prevState, newState);
// return prevState === newState;
// }
);
return (
<div>
<h2>counter bad bug</h2>
<p>badBugCount: {bedbug}</p>
<p>moskitoCount: {moskito}</p>
<button onClick={() => increaseBug({ type: "bad" })}>plus bag bug</button>
</div>
);
}
(1) 대안1: 얕은 비교 기능
대안1의 문제를 해결하는 방법은 얕은 비교 기능을 사용하는 것이다.
저스탠드에서는 비교 함수 중에서 얕은 동등성 비교를 할 수 있는 함수를 제공한다.
아까전에 create 함수는 그대로 사용하면서 state의 사용부분에서 다음같이 쓴다.
이 방법은 거대한 중앙부 객체에서 slice state를 해올 때, 가져온 값들을 묶어줄 때 유용하다.
import React from "react";
import useMainStore from "../stores/useMainStore";
import { useShallow } from "zustand/react/shallow";
export default function CounterBadBug() {
console.log("counterBadBug.렌더링");
const increaseBug = useMainStore((state) => state.increaseBug);
// 얕은 동등성 비교
// return 객체의 얕은 속성인 bedbug와 moskito를 비교함
const { bedbug, moskito } = useMainStore(
useShallow((state) => ({
bedbug: state.counter.bugs.bedbug,
moskito: state.counter.bugs.moskito,
}))
);
return (
<div>
<h2>counter bad bug</h2>
<p>badBugCount: {bedbug}</p>
<p>moskitoCount: {moskito}</p>
<button onClick={() => increaseBug({ type: "bad" })}>plus bag bug</button>
</div>
);
}
(이거 작동원리를 잘 모르겠다.
얕은 동등성 비교이면 아래처럼 동등성 비교 대상이 객체인 경우, 다른 걸로 판단되어야 하지 않나?)
import { useShallow } from "zustand/react/shallow";
const useMainStore = create((set) => ({
counter: {
bugs: {
ladybug: {
count: 0,
},
moskito: {
count: 0,
},
bedbug: {
count: 0,
},
},
},
increaseBug: ({ type }) =>
set((state) => {
if (type === "good") {
return {
counter: {
...state.counter,
bugs: {
...state.counter.bugs,
ladybug: { count: state.counter.bugs.ladybug.count + 1 },
},
},
};
}
if (type === "bad") {
return {
counter: {
...state.counter,
bugs: {
...state.counter.bugs,
moskito: { count: state.counter.bugs.moskito.count + 1 },
bedbug: { count: state.counter.bugs.bedbug.count + 1 },
},
},
};
}
}),
}));
export default function CounterBadBug() {
console.log("counterBadBug.렌더링");
const increaseBug = useMainStore((state) => state.increaseBug);
const { bedbug, moskito } = useMainStore(
useShallow((state) => ({
bedbug: state.counter.bugs.bedbug,
moskito: state.counter.bugs.moskito,
// (state) => state.counter.bugs
}))
// 아래는 안됨
// (state) => state.counter.bugs,
);
return (
<div>
<h2>counter bad bug</h2>
<p>badBugCount: {bedbug.count}</p>
<p>moskitoCount: {moskito.count}</p>
<button onClick={() => increaseBug({ type: "bad" })}>plus bag bug</button>
</div>
);
}
(2) 대안2: 사용자 정의 비교 함수
얕은 비교로도 안되는 경우, 사용자 정의 비교 함수를 넣어 객체 간 동등성 비교를 직접 구현할 수 있다.
이 경우, 깊이까지 비교를 하면 성능이 많이 떨어지니 주의하자.
//@ 스토어
// 사용자 정의 함수를 넣을 수 있다
import { createWithEqualityFn } from "zustand/traditional";
const useMainStore = createWithEqualityFn((set) => ({
counter: {
bugs: {
ladybug: 0,
moskito: 0,
bedbug: 0,
},
},
increaseBug: ({ type }) =>
set((state) => {
if (type === "good") {
return {
counter: {
...state.counter,
bugs: {
...state.counter.bugs,
ladybug: state.counter.bugs.ladybug + 1,
},
},
};
}
if (type === "bad") {
return {
counter: {
...state.counter,
bugs: {
...state.counter.bugs,
moskito: state.counter.bugs.moskito + 1,
bedbug: state.counter.bugs.bedbug + 1,
},
},
};
}
}),
}));
export default useMainStore;
//@ badbug 컴포넌트. 사용자 정의 함수 삽입
export default function CounterBadBug() {
console.log("counterBadBug.렌더링");
const increaseBug = useMainStore((state) => state.increaseBug);
const { bedbug, moskito } = useMainStore(
(state) => ({
bedbug: state.counter.bugs.bedbug,
moskito: state.counter.bugs.moskito,
}),
(prevState, newState) =>
prevState.bedbug === newState.bedbug &&
prevState.moskito === newState.moskito
);
return (
<div>
<h2>counter bad bug</h2>
<p>badBugCount: {bedbug}</p>
<p>moskitoCount: {moskito}</p>
<button onClick={() => increaseBug({ type: "bad" })}>plus bag bug</button>
</div>
);
}
best practice
1) combine store
const nuts = useBearStore((state) => state.nuts)
단일 스토어에서 모든 상태를 관리하면 대규모 프로젝트에서는 복잡할 수 밖에 없다.
이럴 때는 store 내부를 관심사에 따라 분리해주면 되는데, 이 때 각 부분을 slice로 나눈다.(이 방식은 redux와 유사하다)
이 부분은 아래 링크에서 이해하기 쉽게 다루고 있다.
https://github.com/pmndrs/zustand/blob/HEAD/docs/guides/slices-pattern.md
zustand/docs/guides/slices-pattern.md at 66f3a029fbc4640b76c26959e01a5caa857c04dc · pmndrs/zustand
🐻 Bear necessities for state management in React. Contribute to pmndrs/zustand development by creating an account on GitHub.
github.com
참고 자료
https://docs.pmnd.rs/zustand/guides/updating-state
Zustand Documentation
Zustand is a small, fast and scalable bearbones state-management solution, it has a comfy api based on hooks
docs.pmnd.rs
https://github.com/pmndrs/zustand/blob/HEAD/docs/guides/flux-inspired-practice.md
zustand/docs/guides/flux-inspired-practice.md at 66f3a029fbc4640b76c26959e01a5caa857c04dc · pmndrs/zustand
🐻 Bear necessities for state management in React. Contribute to pmndrs/zustand development by creating an account on GitHub.
github.com