728x90

Zustand는 상태를 유지하는 store를 만드는데 사용되는 라이브러리입니다. 

import { create } from 'zustand';

type StoreState = {
  count: number;
  text: string;
  inc1: () => void;
};

export const useEx1Store = create<StoreState>((set) => ({
  count: 0,
  text: 'hello',
  inc1: () => set((prev) => ({ count: prev.count + 1 })),
}));

 

이때 위의 예시에서 반환되는 값 useEx1Store의 타입은 아래와 같습니다.

export interface StoreApi<T> {
    setState: SetStateInternal<T>;
    getState: () => T;
    getInitialState: () => T;
    subscribe: (listener: (state: T, prevState: T) => void) => () => void;
}

 

setState는 불변성을 활용하여 값을 설정하거나 이전 값을 활용하여 갱신이 가능합니다.  setState의 경우에는 내부적으로 Object.assign으로 구현되어 있으며 , 그렇기에 store에서 이전 상태와 새 상태를 병합하여 새 객체를 반환합니다.

getState와 getInitialstate로 상태 및 초기 상태를 얻을 수 있습니다.

마지막으로 subscribe를 통해서 구독을 하여, 상태가 변경되었을 때 콜백함수가 실행되게 할 수 있습니다. 이러한 구독을 활용하여, 리엑트에서 상태를 공유하는 다른 컴퍼넌트들이 리렌더링하도록 할 수 있습니다.

 

선택자함수를 사용하여 수동 렌더링 최적화 하기

import { create } from 'zustand'

const useStore = create((set) => ({
  count: 1,
  inc: () => set((state) => ({ count: state.count + 1 })),
}))

function Counter() {
  const { count, inc } = useStore()
  return (
    <div>
      <span>{count}</span>
      <button onClick={inc}>one up</button>
    </div>
  )
}

 

아래 코드는 zustand의 공식페이지에 나와 있는 예시 입니다. 보통 이 예시를 보고 zustand의 간단한 사용법에 혹해서 도입하는 경우가 많다고 생각합니다. 하지만 지금은 상태가 단 하나뿐이라서 큰 문제가 없지만,  만약에 처음에 보였던 예시처럼 count와 text 두 가지 상태를 공유하고 count만 변경하는 경우에 문제가 발생합니다. 이를 확인하기 위해서 아래와 같은 예시를 만들었습니다.

 

각각의 컴퍼넌트를 렌더링하는 앱

import './App.css';
import { CountChanger, ZustandEx1, ZustandEx2 } from './test/chp7/ZustandEx1';

function App() {
  return (
    <>
      <ZustandEx1 />
      <ZustandEx2 />
      <CountChanger />
    </>
  );
}

export default App;

 

선택자 사용 유무 리렌더링을 비교하기 위해서 분리함

import { useEx1Store } from './count';

export const ZustandEx1 = () => {
  const { text } = useEx1Store();
  console.log('리랜더링 체크 1');
  return (
    <>
      <div>{text}</div>
    </>
  );
};

export const ZustandEx2 = () => {
  const text = useEx1Store((state) => state.text);
  console.log('리랜더링 체크 2');
  return (
    <>
      <div>{text}</div>
    </>
  );
};

export const CountChanger = () => {
  const inc1 = useEx1Store((state) => state.inc1);
  console.log('리렌더링함?');
  return <button onClick={inc1}>Zustand Count 증가</button>;
};

 

count.ts ( zustand 코드)

import { create } from 'zustand';

type StoreState = {
  count: number;
  text: string;
  inc1: () => void;
};

export const useEx1Store = create<StoreState>((set) => ({
  count: 0,
  text: 'hello',
  inc1: () => set((prev) => ({ count: prev.count + 1 })),
}));

 

예시 페이지

 

count 값을 증가하는 함수가 존재하고, 전역 store에는 count와 text가 존재합니다.

이때 CountChanger는 단순히 count 값을 증가시키는 store 내부의 함수이고, ZustandEx1에서는 선택자 없이 text를 사용 Ex2에서는 선택자를 활용하여 상태 증가를 시키고 있습니다.

그리고 버튼을 누르게 되면 콘솔을 통해서 리렌더링 횟수를 확인할 수 있습니다.

 

초기에 렌더링 될 때 3가지의 콘솔이 모두 찍히고, 이후 버튼 클릭시에 count 와 전혀 무관한 것 같은 ZustandEx1이 리렌더링 됨을 확인할 수 있습니다. 이는 store를 구독하고 있기 때문에, store 상태가 변경될 때마다 리렌더링 되기 때문입니다.

 

이를 해결하기 위한 방법이 선택자 함수 입니다.

  const text = useEx1Store((state) => state.text);

store 내부에 있는 상태 중에서 사용하고 싶은 상태를 선택하게 되면 해당 값을 제외한 상태 변경에는 store를 구독했어도 리렌더링 되지 않습니다.

이러한 것을 리액트 훅을 사용한 마이크로 상태 관리 책에서는 수동 렌더링 최적화 라고 부릅니다.

 

이를 활용하면 파생 상태에 대한 리렌더링도 최적화 할 수 있습니다. 만약 count 값 2개를 합친 값의 최적화를 하는 경우에는 1 + 2 는 3입니다. 하지만 만약 값이 변경되어 0 + 3 이 되어도 파생 값은 변경되지 않았기에 리렌더링할 필요가 없습니다. 이때 아래와 같이 선택자에서 파생 값(지금은 count 와 count2의 합)을 선택하면 리렌더링이 최적화 됩니다.

  const text = useEx1Store((state) => state.count + state.count2);

 

 

교재에서는 Zustand의 장점으로는 리액트와 동일한 모델을 사용해 라이브러리의 단순성과 번들 크기가 작다는 점을 꼽고 있고, 단점으로는 선택자를 이용한 수동 렌더링 최적화로 꼽고 있습니다. 객체 참조 동등성을 이해해야 하며, 선택자 코드를 위해 보일러플레이트 코드를 많이 작성해야 한다고 합니다. 

 

참고문헌

https://zustand-demo.pmnd.rs/

 

Zustand

 

zustand-demo.pmnd.rs

공식 소스 코드

 

리액트 훅을 활용한 마이크로 상태 관리 (다이시 카토 지음)

 

 

+ Recent posts