zustand 공식문서와 함께 훑어보기
2025-01-13

현재 프로젝트는 redux를 사용해 상태관리중이다. 하지만 보일러플레이트가 많기도하고 무겁기도 해서 zustand로 변경을 검토하면서 공식문서를 보고 습득한 내용을 정리하려고 한다!

24년 1월부터의 통계를 살펴보면 zustand가 압도적인걸 알 수 있다. 물론 최근에는 React-Query등을 이용한 클라이언트측의 데이터 캐싱, RSC의 등장 등의 여러 이유로 전역상태관리 라이브러리의 필요성이 점점 줄어드는것 같긴 하지만, 통계를보고 zustand는 어떤점때문에 이렇게 점유율이 높아졌을까 궁금해하면서 보다보니 재미있게 봤다.


Zustand 그게 무슨뜻인데?

독일어로 상태 라고 한다. 자기를 가볍고 빠르면서 확장성있는 상태관리 솔루션이라고 소개하고있고 실제로 다른 솔루션에 비해 매우 가벼운걸 볼 수 있다.

이름값 하듯이 상당히 간단한 api를 제공 하지만(소스역시 간단하다..) 리액트 동시성, 컨텍스트 손실문제 등 여러 이슈를 다루는데 공들였고 리액트 환경에서 이런 문제들을 올바르게 수행하는 솔루션중 거의 유일하니 우습게보지 말라고 말하고있다...

zustandimmutable state model을 기반으로해 redux와 비슷한 점이 있고, 렌더링 최적화도 redux와 큰차이는 없고 Selector를 이용해 수동으로 렌더링 최적화 하는걸 권장한다.


Usage

type State = {
    number:number;
}
type Action = {
    inc:(number:number) => void;
    dec:(number:number) => void;
}
const useNumberStore = create<State & Action>(set => ({
    number:0,
    inc:() => {
        set(state => ({number:state.number + 1}))
    },
     dec:() => {
        set(state => ({number:state.number - 1}))
    }
}))
function NumberComponent(){
    const number = useNumberStore(state => state.number);
    const inc = useNumberStore(state => state.inc);

    useEffect(() => {
        // store 업데이트시 실행될 cb
        useNumberStore.subscribe((state,prevState) => {
            console.log("update!",state,prevState)
        })
    },[])

    return <div>
    <h1>number is... {number}</b1>
    <button onClick={inc}>inc</button>
    </div>
}

create는 여러 유틸 기능(getState, setState, getInitialState 등..)이 추가된 react-hook을 생성해준다. 이 hook을 컴포넌트에서 구독해서 사용하면 되는데 놀라운건 별도의 Provider가 필요가 없다는점이다. 사실 저런패턴에서 상태가 공유된다는것은 클로저를 이용한게 아닐까 생각하고 소스를 내려받아봤는데 클로저를 사용하고있었다!

곰곰히 생각해봤는데 모듈레벨에서 store를 생성하고 클로저로 상태를 공유한다면 '테스트는 어떻게진행될까' 하는 궁금함이 생겼다. 물론 beforeAll 등으로 컨트롤 할수는 있겠지만 기존 Provider패턴이 아니다보니 '수동으로 컨트롤해주면 미스가 날법도 한데'라는 생각을 가지면서 계속 훑어나갔다.

액션을 별도의 모듈로 빼서 분리의 이점을 가져가고싶으면 그거도 추천한다고 한다.

// numberStore.js
const useNumberStore = create((set,get,store) => ({
    number:0,
}))

// numberStore.actions.js
export const inc = () => {
    useNumberStore.setState(state => {number:state.number+1})
}
export const dec = () => {
    useNumberStore.setState(state => {number:state.number-1})
}

하지만 기본적으로 zustandAction과 State가 Store한곳에 위치하는걸 추천하고 내생각에도 액션이 분리되면 컨벤션도 고민해야하고 모듈이 많아질거같아 한곳에 있는것이 낫지않을까 싶다🥹

Action함수의 set을 보면 흔히 우리가 아는 {...prev,foo:"bar"}의 형태가 아닌걸 볼 수 있다. 상태 업데이트시 Object.assign처럼 얕게 병합되기 때문인데 해당 옵션을 쓰고싶지 않다면 set함수 2번째인자에 true값을 주고 스프레드 연산자를 이용하면 된다.

반면 뎁스가 깊은 object를 업데이트할때는 스프레드 연산자를 이용해야만 하는데 코드가 장황해지니 공식문서에 나와있는 유의사항만 확인해서 Immer와 같은 라이브러리를 이용해도 되고, 문서상으론 직접 Immer를 종속성으로 넣어서 설치후 zustand-middleware에서 제공하는 immer를 사용하라고 나와있지만 zustandpackage.json에는 immerpeerDependencies로 명시되어 있다.

별도의 설치를 안해도 immer가 동작하는걸 보면 zustand 설치시 패키지매니저가 peerDependencies를 자동으로 설치해 사용이 가능한게 아닐까 싶다...


Pattern

zustand는 보일러플레이트가 없다고 소개하지만 flux패턴과 redux에 영감을 받은만큼 추천되는 규칙은 존재한다.

single-store pattern

app에서 쓰이는 모든 전역상태는 1개의 store에서 관리하는 패턴이다. 그런만큼 무의미한 렌더링을 방지하기위해 위에서 말한 Selector를 이용한 수동 최적화가 중요할 듯 싶다. 1개의 store에서 전부 관리하는만큼 규모가 커지면 slices-pattern을 이용하는걸 추천한다고 한다.

export const createNumberStore = set => ({
	number:1,
	add:() => set(state => ({number:state.number + 1}))
})

export const createStringStore = set => ({
	string:"string",
	upper:() => set(state => ({string:state.string.toUpperCase()}))
	})
	
export const useBoundedStore = create((...arg) => ({
	...createNumberStore(...arg),
	...createStringStore(...arg)
}))
// update at once
export const createNumberStringSlice = (set,get) => ({
	addAndUpper:() => {
		get().add();
		get().upper();
	}
})
// usage
const number = useBoundedStore(state => state.number);
const string = useBoundedStore(state => state.string);

Redux-like pattern

redux 패턴이 익숙하다면 reducer와 dispatch 함수를 선언하고 사용하거나 zustand-middleware에 redux()를 이용해 주면 된다.

const reducer = (state,{type}) => {
    switch(type){
        case "ADD":
            return {number:state.number + 1};
        case "DEC":
            return {number:state.number - 1};
    }
}

const useNumberStore = create(set => ({
    number:0,
    dispatch:(arg) => {
        set(state => reducer(state,arg))
    }
}))
// usage
const dispatch = useNumberStore(state => state.dispatch);
dispatch({type:"ADD"});

사실 이 패턴을쓸거면 보일러플레이트에서 자유로운 zustand의 사용의 의미가 약간은 줄어들지않나 해서 single-store pattern이 나을거같다는 생각을 가져본다..


Middleware

제공하는 Middleware 자체가 몇개 없긴하지만 이정도는 사용할법 하겠다 싶은것만 정리해본다 👻

immer

위에서 잠깐 언급한 녀석이다. zustand의 상태 업데이트를 immer스럽게. 즉 불변성을 신경쓰지 않고 직접 수정하듯 업데이트 할 수 있게 해준다. 상태 불변성은 배열,객체등 참조타입 데이터를 직접 수정하는게 아닌 아예 새로운 참조를 만드는 패턴이다

const useNumberStore = create(immer(set => ({
    number:0,
    inc:() => {
        set(state => {
            state.number += 1;
        })
    }
})))

combine

combine은 initialState와 추가 state를 만드는 함수를 인자로 받아 좀더 응집력 있는 store를 만들게 해준다. Typescript를 사용한다면 자동으로 타입까지 추론된다!

// combine(initialState, additionalStateCreatorFn)
const initialState = {number : 0};

const useNumberStore = create(combine(initialState,(set) => ({
    inc:() => {
        set(state => ({number:state.number + 1}))
    }
}))) 

persist

의미그대로 app을 reload,restart해도 상태를 유지시키게 해준다. localStorage가 default이며 storage에 관한 option을 인자를 추가로 받는다.

const useNumberStore = create(persist(set => ({
    number:0,
    inc:() => {...}
}),{name:"storage-name"}))

좋은 미들웨어지만 사용할때 storage에 저장되는 성격이다보니 기존 store와는 다른 네이밍 컨벤션이라도 적용하는게 좋을거같다.

devtools

Redux DevTools Extension을 redux없이 사용 가능하게 해주는 미들웨어다. 여태까지와 마찬가지로 stateCreatorFn을 받고 devtoolsOptions은 공식문서를 참조하도록 하자 ^^

const nextStateCreatorFn = devtools(stateCreatorFn, devtoolsOptions)

Middleware를 여러개를 사용하려면 콜백함수로써 계속 넘겨주며 사용하면 된다. create(devtools(immer(...arg))) 그렇기 때문에 항상 쓰는 Middleware 패턴은 별도의 hooks로 빼서 custom-create를 만들어 주는것도 좋은 방법이라 생각한다!


이밖에도 createStore api는 react-hook형식이 아닌 vanilla-store + 유틸리티를 return해주고. 얕은 비교를통해 불필요한 re-render를 방지하는 useShallow,shallow등 소개하지 않은 api들 및 SSR/Hydration 및 Next.js등 SSR환경에서의 세팅등 볼만한 글이 많으니 가벼운마음으로 공식문서를 참조하는게 좋을것같다!

당장 테스트코드가 없기에 리스크가있어 프로젝트에 도입하지는 못하지만 왜 사랑받는지 훑어보는 좋은 시간이었다. 하지만 처음에 말했듯이 RSC등 클라이언트 전역상태관리의 필요성이 점점 떨어지고있다.. 항상 변화를 따라가려고 노력해야 할것같다는 생각이 많이든다🤢