Frontend Engineer - 박준형

Published on

Zustand와 서로 알아가는 중

Authors
  • Name
    Twitter

Redux, Mobx에 질려버린 사람들이 요즘은 아토믹한 전역상태관리 라이브러리를 많이 사용하려고 옮겨가는것같다. 가장 유명한 세가지 recoil, zustand, jotai이다 (물론 아직도 redux가 압도적으로 생태계를 지배하고있기는 하다.)

threeTrends

다음 사내 프로젝트에 들어갈 라이브러리 탐색을 하고있던 찰나 노마드코더 커뮤니티에서 zustand를 추천받았다!

chat

zustand

정말 간단한 예로 로그인 상태관리를 해보겠다.

zustand의 스토어는 hooks의 형태다. hook에는 primitives(원시값), 객체, 그리고 함수도 선언할수있다. state는 불변성을 가지며 set 함수가 해당 state를 merge시키게 도와준다

import create from 'zustand'

const useAuthStore = create((set) => ({
  isLoggedIn: false,
  user: {},
  setLoggedIn: () => set((state) => ({ isLoggedIn: true, user: state.user })),
  setLoggedOut: () => set(() => ({ isLoggedIn: false, user: {} })),
}))

이제 이 코드를 컴포넌트에 녹여내볼 차례다.

이제 어느 컴포넌트에서든 Provider없이 state를 선택하고 변화에 반응하게 될것이라고 한다.

아래 코드에서는 유저가 로그인했는지 안했는지 감지하고 로그인상태면 로그아웃을 유도하고 ?? 로그아웃상태면 로그인을 유도하는?? 컴포넌트다

function LoginPage() {
  const { isLoggedIn, setLoggedIn, setLoggedOut } = useAuthStore((state) => state)
  if (isLoggedIn) {
    return (
      <div>
        <span>안녕하세요!</span>
        <button onClick={setLoggedOut}>로그아웃</button>
      </div>
    )
  } else {
    return (
      <div>
        <button onClick={setLoggedIn}>로그인</button>
      </div>
    )
  }
}

아주 놀랍게도 겨우 코드 7줄을 작성했는데 로그인전역상태관리가 되어버린다. mobx... redux면 뭐 벌써부터 머리가 어질어질하긴하다.

redux면 뭐 디스패치... 유즈셀렉터... 작성할게 엄청나고
mobx면 authClass 에 observable state 선언하고...
코드가 분명 7줄보다는 그 N배가 될수밖에 없다.

제일 신기한점이 Provider 없이 어떻게 상태를 감지할까였다. recoil찍먹당시 RecoilRoot Provider를 컴포넌트보다 상위 루트에 배치해야했었고 Mobx도 마찬가지였다.

알고보니 zustand는 매우 간단하게 Flux패턴을 따르는 상태 관리 라이브러리라고 소개된다.

zustand 동작원리

zustand는 Sub/Pub 모델을 기반으로 이루어져있다. 스토어의 상태 변경이 일어날 때 실행할 Listener를 모아두었다가(Sub) 상태가 변경되었을때 등록된 Listener들에게 상태가 변경되었다고 알려준다(Pub)

zustandCode

상태변경

먼저 setState부터 보겠다

이 함수는 prevState를 기반으로 nextState를 리턴하는 함수 혹은 변경하려는 state 값을 전달받는다.

store.setState((state) => ({ counter: state.counter + 1 }))

이런 동작을 구현하려면 prevState를 함수 파라미터로 전달하는 방법과 함수를 갱신하는 방법이 필요하다. 함수를 전달받게 된다면 prevState를 파라미터로 넘겨주는 식으로 nextState를 정의한다.

const setState = (partial) => {
    const nextState = typeof partial === 'function' ? partial(state) : partial
    ...
}

이렇게 정의된 nextState가 기존 state와 다른경우 갱신한다. 갱신방법은 가장 효과적이고 간단한 Object.assign을 사용한다

if (nextState !== state) {
  const prevState = state

  state = Object.assign({}, state, nextState)

  listeners.forEach((listener) => listener(state, previousState))
}

Object.assign은 얕은 복사를 수행하는데 중첩된 형태의 스토어를 만들 경우 setState를 호출할때 유의해야한다

구독

state를 Subscription하는 함수를 등록할때는 subscribe함수를 사용한다. 모든 상태의 변화를 구독할수도있고, 일부만 구독할수도있다.

const subscribe = (listener, selector, equalityFn) => {
  if (selector || equalityFn) {
    return subscribeWithSelector(listener, selector, equalityFn)
  }
  listeners.add(listener)

  return () => listeners.delete(listener)
}

만약 listener만 전달하지않고 두번째 인자로 함수의 셀렉터까지 전달한다면 셀렉터로 꺼낸 상태의 일부를 어딘가 보관하고 상태가 바뀔때마다 이전 슬라이스와 비교하는 과정이 필요하다.

const subscribeWithSelector = (listener, selector, equalityFn = Object.is) => {
  let currentSlice = selector(state)

  function listenerToAdd() {
    const nextSlice = selector(state)
    if (!equalityFn(currentSlice, nextSlice)) {
      const previousSlice = currentSlice
      listener((currentSlice = nextSlice), previousSlice)
    }
  }

  listeners.add(listenerToAdd)
  return () => listeners.delete(listenerToAdd)
}

결과물

const authStore = create(set => ({
  isLoggedIn: false,
  user: {},
  setLoggedIn: () => set((state) => ({ isLoggedIn: true , user: state.user})),
  setLoggedOut: () => set(() => ({ isLoggedIn: false, user: {} })),
}));

authStore.subscribe(state => console.log('Something's changed: , state));// 어떤 상태가 변경되더라도 로그가 출력됨
authStore.subscribe(
  state => console.log('isLoggedIn is changed: ', isLoggedIn),
  state => state.isLoggedIn
);

authStore.subscribe(
  state => console.log('user has been changed: ', user),
  state => state.user
);


authStore.setLoggedIn({user : { name:'BBAK-JUN' } }); // user객체와 isLogged의 상태 변경