Tech/Zustand

[Zustand] Zustand 가이드북(간단한 사용법부터 Pro Tips까지)

Lamue 2023. 10. 24. 17:08

React에서는 전역상태 관리 라이브러리를 사용하여 체계적인 상태관리를 할 수 있습니다. React에서 활용되는 상태 관리 라이브러리들은 여러 개가 있습니다. 대표적으로 Redux, Recoil, Zustand, Jotai, MobX 등이 존재합니다. 

 

그렇다면 이 중에 어떤 상태관리 라이브러리를 사용해야할까요 ? 

사실 이 문제에 정답은 없습니다. 지인들을 대상으로 간단한 설문을 진행한 결과만 보더라도 Redux, Recoil, MobX, Zustand 등을 실제 사용하고 있었고 회사별, 업무별로 사용하는 라이브러리가 달랐습니다. 

 

그렇다면 어떤 상태관리 라이브러리를 배워두는 것이 향후 도움이될까요?

다음은 npm trends를 통해 상태 관리 라이브러리의 이용 추이를 확인해본 이미지입니다. 

최근 1년 상태관리 라이브러리 이용 추이(출처: npm trends)

역시 아직까지는 Redux가 강세를 보이고 있네요. 근데 Redux 아래에서 무섭게 치고 올라오는 라이브러리가 있는 거 같습니다.

보다 정확한 분석을 위해 Redux를 제거해볼까요? 

 

최근 1년 상태관리 라이브러리 이용 추이(Redux 제거 버전)

다른 라이브러리들은 지난 1년간 비슷한 이용 추이를 보이고 있지만, Zustand는 상당히 가파른 상승 추이를 보이고 있습니다. Redux 이외의 상태관리 라이브러리를 시작하고 싶은 개발자 및 기업들이 Zustand 사용에 대해 고민하지 않을 이유가 없어보입니다.

 

오늘은 현재 가장 이슈가 되고 있는 Zustand에 대해 소개하고, 간단한 사용법부터 Pro Tips까지 상세히 알아보겠습니다. 

 

 

Zustand란 ?

Zustand는 react에서 사용할 수 있는 상태 관리 라이브러리 중 하나로 redux와 같이 flux 패턴을 활용하고 있습니다.

Flux 패턴
Flux 패턴은 사용자 입력을 기반으로 Action을 만들고, Action을 Dispatcher에 전달하여 Store(Model)의 데이터를 변경한 뒤 View에 반영하는 단방향의 흐름으로 애플리케이션을 만드는 아키텍처입니다.

 

 

간단한 store 만들기

Zustand에 대해 본격적으로 알아보기 전에 간단한 store를 만들며 Zustand에 매력에 대해 알아보겠습니다. 먼저 패키지를 설치하고, Counter를 만들기 위한 store를 만들어 보고, 이후 실제 Counter를 구현해보겠습니다.

 

1. 패키지 설치

// npm 설치
$ npm install zustand

// yarn 설치
$ yarn add zustand

터미널을 이용하여 Zustand를 설치해줍니다.

 

2. store 작성

import { create } from 'zustand';

interface UseNumberBaseStore {
    numberA: number;
    numberB: number;
    increaseNumberA: () => void;
    increaseNumberB: (value: number) => void;
}

const useNumberBaseStore = create<UseNumberBaseStore>()((set, get) => ({
    numberA: 0, // store state
    numberB: 0, // store state
    // numberA 증가 함수
    increaseNumberA: () =>
        set((state) => ({
            numberA: state.numberA + 1, // state를 이용하여 state 값 변경
        })),
    // numberB 증가 함수
    increaseNumberB: (value: number) =>
        set({
            numberB: get().numberB + value, // get을 이용하여 state 값 변경
        }),
}));

export default useNumberBaseStore;

Counter 기준으로 필요한 store의 코드는 위의 20줄 안팎의 코드가 끝입니다. 심지어 Type을 위한 interface가 1/3 가량을 차지하네요.

 

3. Counter 에 store 적용하기

import React from 'react';
import useNumberBaseStore from '../modules/zustand/base';

const Counter = () => {
    // 한번에 가져오는 경우
    const { numberA, numberB, increaseNumberA, increaseNumberB } = useNumberBaseStore();

    // 하나씩 가져오는 경우
    const numberA = useNumberBaseStore((state) => state.numberA);
    const numberB = useNumberBaseStore((state) => state.numberB);
    const increaseNumberA = useNumberBaseStore((state) => state.increaseNumberA);
    const increaseNumberB = useNumberBaseStore((state) => state.increaseNumberB);

    return (
        <div>
            <h2>numberA : {numberA}</h2>
            <h2>numberB : {numberB}</h2>
            <button onClick={increaseNumberA}>A 증가</button>
            <button onClick={() => increaseNumberB(3)}>B 증가</button>
        </div>
    );
};

export default Counter;

Zustand는 위와 같이 간단하게 적용가능합니다. 이미 눈치 채신 분들도 있겠지만 위의 코드에선 provider가 사용되지 않았습니다. 

사실 위의 코드 뿐만 아니라 Zustand에선 provider를 사용하지 않습니다. 그럼에도 성능 측면에서 Redux와 Recoil보다 우수한 성능을 보이고 있습니다.

 

 

Zustand의 장점

Zustand가 Redux, Recoil과 같은 라이브러리에 비해 갖는 장점을 정리하면 다음과 같습니다.

  • 전역 스토어를 제공하며, 셀럭터를 포함한 간단한 API를 포함합니다.
  • 간결한 초기설정과 직관적이고 복잡하지 않은 전역변수를 다루는데 유용합니다.
  • 용량이 매우 작습니다. (1.1kb)
  • 개념적으로 Redux와 비슷합니다.
  • 사용중인 필드를 셀렉터로 추적합니다.
  • persist와 같은 부가적인 미들웨어 기능을 지원합니다.
  • provider를 사용하지 않습니다.

위의 장점으로 정리된 사항 중에 일부는 Redux나 Recoil 등의 다른 상태관리 라이브러리에서도 제공하고 있습니다. 하지만 Zustand가 매력적인 이유는 위의 나열된 장점을 '모두' 가진다는 점인 거 같습니다.

 

구체적으로 Zustand가 갖는 장점을 코드와 함께 확인해보겠습니다.

 

1. Simple

Zustand는 간단한 사용법만을 필요로 합니다. create로 store를 만들고, 이를 훅으로 가져와서 사용하는 게 전부입니다.

러닝커브가 있는 다른 라이브러리와 달리 이게 끝입니다. 실제로 공식문서 기준 핵심로직의 코드줄 수가 42줄에 불과하다고 합니다.

// 카운터 예시 zustand
import { useState } from 'react';
import create from 'zustand';

const useCounter = create((set) => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 })),
  decrement: () => set(state => ({ count: state.count - 1 }))
}));

function Counter() {
  const { count, increment, decrement } = useCounter();

  return (
    <div>
      <button onClick={decrement}>-</button>
      {count}
      <button onClick={increment}>+</button>
    </div>
  );
}

 

2. Centralized, action-based state management

중앙화된, 액션기반 상태관리 라이브러리입니다. 이 부분은 recoil와 비교하면서 살펴보겠습니다.

// 카운터 예시 recoil
import { useRecoilState } from 'recoil';
import { atom } from 'recoil';

const counterState = atom({
  key: 'counter',
  default: 0
});

function Counter() {
  const [count, setCount] = useRecoilState(counterState);

  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);

  return (
    <div>
      <button onClick={decrement}>-</button>
      {count}
      <button onClick={increment}>+</button>
    </div>
  );
}

심플함의 측면에선 Recoil도 Zustand와 비교할 만한 수준의 심플함을 자랑합니다. 하지만 Recoil에선 increment와 decrement 가 Counter 컴포넌트 내부에서 선언됩니다.

이는 react의 useState와 비슷하다는 점에서 recoil의 장점 중에 하나로 손꼽힙니다.

 

하지만 개발을 하다 보면 점점 store와 action이 분리된 구조에 대해 불편함을 느낄 수 있습니다. 그래서 몇몇 개발자들은 이를 커스텀 훅으로 만들어 해결하고 있습니다.

반면에 Zustand는 store를 선언하면서 이를 한 번에 해결할 수 있습니다.

import create from 'zustand';

// 선언
const useCounter = create((set) => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 })),
  decrement: () => set(state => ({ count: state.count - 1 }))
}));

// 사용
const { count, increment, decrement } = useCounter();

 

3. middleware: immer & persist

Zustand에는 immer와 persist라는 내장 미들웨어가 있습니다.

우선 immer에 대해 알아보겠습니다.

Zustand에서 immer를 이용하면 복잡한 객체의 업데이트를 간단히 처리할 수 있습니다. create 함수 안을 immer로 감싸기만 하면 됩니다.

import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

const useBeeStore = create(
  immer((set) => ({ 
    bees: 0,
    addBees: (by) =>
      set((state) => {
        state.bees += by
      }),
  }))
)

다음으로 persist에 대해 알아보겠습니다.

다른 상태관리 라이브러리에선 새로고침 시에 데이터를 유지하기 위해 recoil-persist와 같은 파생라이브러리를 사용하거나 별도의 로직을 만들어 저장하는 과정을 수행해야 했습니다.

Zustand에서는 persist middleware를 이용하여 store에 저장되어 있던 데이터들이 새로고침 등과 같이 페이지 이동이 일어나더라도 값을 유지할 수 있게 도와줍니다.

값을 유지하는 원리는 브라우저 저장소를 활용하는 것으로, persist를 설정할 때 어떤 저장소를 활용할지 설정해 줄 수 있습니다. 사용할 수 있는 저장소로는 로컬 스토리지뿐만 아니라 세션스토리지도 지원합니다.

persist는 다음과 같이 사용됩니다.

import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';

interface UseNumberPersistStore {
    numberA: number;
    numberB: number;
    increaseNumberA: () => void;
    increaseNumberB: (value: number) => void;
}

const useNumberPersistStore = create<UseNumberPersistStore>()(
    persist(
        (set, get) => ({
            numberA: 0, // store state
            numberB: 0, // store state
            // numberA 증가 함수
            increaseNumberA: () =>
                set((state) => ({
                    numberA: state.numberA + 1, // state를 이용하여 state 값 변경
                })),
            // numberB 증가 함수
            increaseNumberB: (value: number) =>
                set({
                    numberB: get().numberB + value, // get을 이용하여 state 값 변경
                }),
        }),
        {
            name: 'number-store', // 저장소 key값
            storage: createJSONStorage(() => localStorage), // 저장소
            version: 1.0, // version 정보
        },
    ),
);

export default useNumberPersistStore;

위와 같이 코드를 구성하면 localStorage에 store state 값을 보관할 수 있습니다. 해당 store를 사용하는 page에 접근하여 여러 action을 취해보면 다음과 같이 state 값들이 localStorage에 담겨 있는 것을 확인할 수 있습니다.

이처럼 새로 고침을 하더라도 브라우저 저장소에 있는 데이터 값을 가져와 store의 초기 값으로 설정하기 때문에 페이지 이동에 의해 데이터가 초기화되는 현상을 막아줄 수 있습니다.

 

persist를 이용하여 새로고침 시에도 값을 유지

 

 

Zustand 사용자를 위한 11가지 Pro Tips

Zustand를 프로처럼 사용하기 위한 보다 전문적인 내용에 대해서 알아보겠습니다. 이번 파트는 다른 상태관리 라이브러리에 대한 이해와 리액트에 대한 이해가 있는 독자를 전제로 작성하였습니다.

Zustand에 대한 보다 쉽고 자세한 내용은 이후 포스팅에서 다뤄보도록 하겠습니다.

 

1. Shallow (얕은 복사)

Zustand에서는 렌더링 최적화에 도움 될 수 있는 shallow를 제공해주고 있습니다.

shallow가 사용되어야 하는 경우에 대해 간단히 알아보겠습니다.

먼저 Zustand에 의해 관리되고 있는 데이터들이 리렌더링이 발생되는 경우는 "strict-equality (old === new)"에 해당되지 않을 때입니다.

즉, 이전의 값과 비교했을 때 값이 동일하면 리렌더링이 발생되지 않습니다.

일반적으로 number, string 등의 타입을 이용하여 값을 비교할 때는 값 자체를 비교하기 때문에 문제가 되지 않지만, array나 object를 사용하는 경우는 문제가 발생하게 됩니다.

간단한 예시를 들어보겠습니다.

// store
import { create } from 'zustand';

interface UseNumberShallowStore {
    numberA: number;
    numberB: number;
    numberC: number;
    increaseNumberA: () => void;
    increaseNumberB: (value: number) => void;
    increaseNumberC: () => void;
}

const useNumberShallowStore = create<UseNumberShallowStore>()((set, get) => ({
    numberA: 0, // store state
    numberB: 0, // store state
    numberC: 0, // store state
    // numberA 증가 함수
    increaseNumberA: () =>
        set((state) => ({
            numberA: state.numberA + 1, // state를 이용하여 state 값 변경
        })),
    // numberB 증가 함수
    increaseNumberB: (value: number) =>
        set({
            numberB: get().numberB + value, // get을 이용하여 state 값 변경
        }),
    // numberC 증가 함수
    increaseNumberC: () =>
        set((state) => ({
            numberC: state.numberC + 2, // state를 이용하여 state 값 변경
        })),
}));

export default useNumberShallowStore;
// children component
import React from 'react';
import useNumberShallowStore from '../modules/zustand/shallow';

const ShallowChildren = () => {
    const numberC = useNumberShallowStore((state) => state.numberC);
    const increaseNumberC = useNumberShallowStore((state) => state.increaseNumberC);

    return (
        <div>
            <h2>numberC : {numberC}</h2>
            <button onClick={increaseNumberC}>C 증가</button>
        </div>
    );
};

export default ShallowChildren;
// page
import React from 'react';
import ShallowChildren from '../components/shallowChildren';
import useNumberShallowStore from '../modules/zustand/shallow';

const Shallow = () => {
    // atomic state 방식으로 store 사용
    const numberA = useNumberShallowStore((state) => state.numberA);
    const numberB = useNumberShallowStore((state) => state.numberB);
    const increaseNumberA = useNumberShallowStore((state) => state.increaseNumberA);

    return (
        <div>
            <h2>numberA : {numberA}</h2>
            <h2>numberB : {numberB}</h2>
            <button onClick={increaseNumberA}>A 증가</button>
            <ShallowChildren />
        </div>
    );
};

export default Shallow;

위의 소스를 보면 page에서 atomic state 방식을 통해 store를 사용하고 있습니다.

즉, state 값을 불러올 때 하나의 값만 가져오고 있는 겁니다.

이 상황에서 children component에 있는 C 증가 버튼을 클릭하면 numberA, numberB의 값은 변화가 없기 때문에 "strict-equality (old === new)"에 해당되어 children component만 리렌더링이 발생됩니다.

 

 

하지만 여기서 다음과 같이 page에서 store 데이터를 multiple state-picks로 활용하도록 코드를 변경해 보겠습니다.

// page
import React from 'react';
import ShallowChildren from '../components/shallowChildren';
import useNumberShallowStore from '../modules/zustand/shallow';

const Shallow = () => {
    // multiple state-picks 방식으로 store 사용
    const { numberA, numberB } = useNumberShallowStore((state) => ({
        numberA: state.numberA,
        numberB: state.numberB,
    }));
    const increaseNumberA = useNumberShallowStore((state) => state.increaseNumberA);

    return (
        <div>
            <h2>numberA : {numberA}</h2>
            <h2>numberB : {numberB}</h2>
            <button onClick={increaseNumberA}>A 증가</button>

            <ShallowChildren />
        </div>
    );
};

export default Shallow;

그리고 위와 동일하게 C 증가 버튼을 클릭하면 numberA, numberB의 값은 변화가 없지만, 저장되는 메모리 주소 값이 변경되기 때문에 "strict equality (old === new)"에 해당되지 않아 page 전체가 함께 리렌더링 되는 것을 볼 수 있습니다.

 

위와 같은 상황을 방지하기 위해 사용하는 것이 shallow입니다. shallow를 사용하여 page의 소스를 다음과 같이 변경해 보겠습니다.

// page
import React from 'react';
import { shallow } from 'zustand/shallow';
import ShallowChildren from '../components/shallowChildren';
import useNumberShallowStore from '../modules/zustand/shallow';

const Shallow = () => {
    // multiple state-picks 방식으로 store 사용 (shallow 적용)
    const { numberA, numberB } = useNumberShallowStore(
        (state) => ({
            numberA: state.numberA,
            numberB: state.numberB,
        }),
        shallow,
    );
    const increaseNumberA = useNumberShallowStore((state) => state.increaseNumberA);

    return (
        <div>
            <h2>numberA : {numberA}</h2>
            <h2>numberB : {numberB}</h2>
            <button onClick={increaseNumberA}>A 증가</button>

            <ShallowChildren />
        </div>
    );
};

export default Shallow;

store를 이용하여 값을 가져올 때 shallow를 추가하여 object를 얕게 비교하기 원한다고 Zustand에 알려줍니다.

그런 뒤 C 증가 버튼을 동일하게 클릭해 보면 다음과 같이 children component만 리렌더링이 발생되는 것을 볼 수 있습니다.

 

 

2. State 덮어쓰기

Zustand는 기본적으로 덮어쓰기를 방지하고 있습니다.

Zustand에서 state 값 덮어쓰기를 하고 싶을 땐 set의 두 번째 파라미터 값에 true 를 전달하면 됩니다.

그래서 값을 초기화하거나 값을 삭제하는 등의 기능을 넣고자 한다면 다음과 같이 코드를 작성해 볼 수 있습니다.

import omit from 'lodash-es/omit'; // $ npm install lodash-es
import { create } from 'zustand';

interface UseNumberOverwriteStore {
    numberA: number;
    numberB: number;
    clear: () => void;
    deleteNumberB: () => void;
}

const useNumberOverwriteStore = create<UseNumberOverwriteStore>()((set, get) => ({
    numberA: 2, // store state
    numberB: 3, // store state
    // 초기화
    clear: () => set({}, true),
    // numberB 삭제
    deleteNumberB: () => set((state) => omit(state, ['numberB']), true),
}));

export default useNumberOverwriteStore;

 

3. 반드시 커스텀 훅만 export

Zustand를 넘어서 리액트 사용 시 명심해야할 No.1 Tip입니다.

자세한 내용은 Simplifying useEffect를 통해 확인할 수 있습니다.

 

코드를 통해 커스텀 훅만 export해야 하는 이유를 알아보겠습니다.

// ⬇️ not exported, so that no one can subscribe to the entire store
const useBearStore = create((set) => ({
  bears: 0,
  fish: 0,
  increasePopulation: (by) => set((state) => ({ bears: state.bears + by })),
  eatFish: () => set((state) => ({ fish: state.fish - 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))

// 💡 exported - API 컨슈머가 셀렉터를 작성할 필요 없음
export const useBears = () => useBearStore((state) => state.bears)

위와 같이 커스텀 훅만 export할 경우 다음과 같은 장점이 있습니다.

  1. 보다 깨끗한 인터페이스를 제공합니다.
  2. 모든 곳에서 셀렉터를 반복적으로 작성할 필요가 없습니다.
  3. 실수로 전체 스토어를 구독하는 것을 방지합니다.

아래 코드는 위와 결과가 동일하지만, 전체 스토어를 구독하여 불필요한 리렌더링이 많이 일어납니다.

// ❌ we could do this if useBearStore was exported
const { bears } = useBearStore()

셀렉터 사용은 선택 사항이지만, 반드시 사용해야 한다고 생각합니다.

 

4. 원자적 셀렉터 사용

Zustand는 셀렉터의 리턴값을 이전 렌더링의 리턴값와 비교하여 관심 있는 상태가 변경 되었음을 컴포넌트에 알릴 시기를 결정합니다.

기본적으로 엄격한 동등성 검사(strict equality check)를 통해 이를 수행합니다.

// 🚨 selector returns a new Object in every invocation
const { bears, fish } = useBearStore((state) => ({
  bears: state.bears,
  fish: state.fish,
}))

// 😮 so these two are equivalent
const { bears, fish } = useBearStore()

셀렉터에서 객체 또는 배열을 반환하려는 경우, 얕은 비교(shallow)를 사용하도록 비교 함수를 조정할 수 있습니다. 이 경우 약간의 성능 최적화로 인한 이점을 얻을 수 있습니다.

import shallow from 'zustand/shallow'

// ⬇️ much better, because optimized
const { bears, fish } = useBearStore(
  (state) => ({ bears: state.bears, fish: state.fish }),
  shallow
)

그러나 두 개의 아토믹한 개별 셀렉터를 내보내는 경우 ‘단순함’의 측면에서 더 많은 장점을 갖습니다. 성능 최적화 측면에선 위의 코드와 동일하지만, 가독성과 심플함의 측면에서 장점을 가지므로 개인적으로 아래의 코드를 선호하고 있습니다.

export const useBears = () => useBearStore((state) => state.bears)
export const useFish = () => useBearStore((state) => state.fish)

 

5. 액션과 상태 분리

액션은 스토어의 값을 업데이트하는 함수입니다. 액션의 경우 정적이며 변경되지 않는다는 점에서 기술적으로 "상태"가 아닙니다.

스토어에서 액션을 별도의 객체로 구성하면 성능에 영향을 주지 않으며, 모든 컴포넌트에서 사용할 단일 훅으로 노출할 수 있습니다.

const useBearStore = create((set) => ({
  bears: 0,
  fish: 0,
  // ⬇️ separate "namespace" for actions
  actions: {
    increasePopulation: (by) =>
      set((state) => ({ bears: state.bears + by })),
    eatFish: () => set((state) => ({ fish: state.fish - 1 })),
    removeAllBears: () => set({ bears: 0 }),
  },
}))

export const useBears = () => useBearStore((state) => state.bears)
export const useFish = () => useBearStore((state) => state.fish)

// 🎉 one selector for all our actions
export const useBearActions = () => useBearStore((state) => state.actions)

이제 액션을 구조 분해하고, 그 중 하나만 "사용(use)"해도 성능상의 문제가 발생하지 않습니다.

const { increasePopulation } = useBearActions()

위의 "원자 셀렉터(아토믹 셀렉터)" 팁과 모순되는 것처럼 들릴 수 있지만 실제로는 그렇지 않습니다. 액션은 절대 변하지 않기 때문에 우리가 "모두"를 구독하는 것은 중요하지 않습니다.

즉, action 객체 전체를 단일 원자 조각으로 볼 수 있고, 이로 인한 성능상의 문제는 발생하지 않는 것입니다.

 

6. 액션을 이벤트로 모델링

액션은 세터(setter)가 아닙니다. 이는 useReducer, Redux 또는 Zustand로 작업하는지 여부에 관계없이 일반적인 팁입니다. 이에 대해선 Redux style guide에서 보다 자세히 알아볼 수 있습니다.

이전의 예제를 통해 우리는 이미 이 패턴을 사용하고 있었습니다.

로직(예: "인구 증가")은 스토어에 있습니다. 컴포넌트는 액션을 호출하고, 스토어는 이를 사용하여 수행할 액션을 결정합니다.

 

7. 스토어의 스코프를 작게 유지

전체 앱에 대해 단일 스토어를 가져야 하는 Redux와 달리 Zustand는 여러 개의 작은 스토어(slice)를 가질 것을 권장합니다.

이 때 각 스토어는 단일 상태를 담당할 수 있습니다. 만약 이들을 결합해야 하는 경우 커스텀 훅을 사용하여 결합할 수 있습니다.

const currentUser = useCredentialsStore((state) => state.currentUser)
const user = useUsersStore((state) => state.users[currentUser])

Zustand에는 스토어를 슬라이스를 사용해 결합하는 방법(combine stores into slices)이 있습니다. 하지만 실제로 이러한 방법을 많이 사용하진 않습니다.
특히 TypeScript가 관련된 경우에는 문제의 해결이 간단하지 않습니다. 만약 TypeScript에서 해당 기능이 필요한 경우 Redux Toolkit을 사용하는 것이 차선책이 될 수 있습니다.

 

8. 다른 라이브러리와 같이 사용하기

앱의 대부분의 상태는 서버 또는 URL 상태이기 때문에 실제로 여러 Zustand 스토어를 자주 결합할 필요가 없었습니다.

예를 들어 Zustand 저장소를 useQuery 또는 useParams와 결합할 가능성이 두 개의 개별 스토어를 결합할 가능성보다 큽니다.

다른 훅을 Zustand 스토어와 결합해야 하는 경우 커스텀 훅으로 많이 사용합니다.

const useFilterStore = create((set) => ({
  applied: [],
  actions: {
    addFilter: (filter) =>
      set((state) => ({ applied: [...state.applied, filter] })),
  },
}))

export const useAppliedFilters = () =>
  useFilterStore((state) => state.applied)

export const useFiltersActions = () =>
  useFilterStore((state) => state.actions)

// 🚀 combine the zustand store with a query
export const useFilteredTodos = () => {
  const filters = useAppliedFilters()
  return useQuery({
    queryKey: ['todos', filters],
    queryFn: () => getTodos(filters),
  })
}

 

9. 초기화하는 간편한 방법( ver. 1)

맨 처음 초기화하는 값을 별도로 선언해 주고 reset 함수로 이 값을 업데이트하는 방법입니다.

// CounterStore.tsx
import create from "zustand";

interface CounterState {
  count: number;
}

const intialState = {
  count: 0,
};

export interface CounterStore extends CounterState {
  increment: () => void;
  decrement: () => void;
  resetCounterStore: () => void;
}

const useCounterStore = create<CounterStore>((set) => ({
  ...intialValue,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  resetCounterStore: () => set(intialState),
}));

export default useCounterStore;

initialState를 파일 내부에 선언하고 reset에서 값을 업데이트해 주는 형식입니다.

이러한 방법을 통해 초기화 함수를 활용할 수도 있고, 코드를 분리함으로써 초기 데이터를 읽기도 쉬워졌습니다.

 

10. 타입스크립트와 함께 쓰기(ver. 2)

바로 위의 예제에서는 CounterState와 이를 확장해 state를 바꾸는 action를 더해 store를 만들었습니다.

이렇게 사용하고 보니 "state와 action을 동등한 계층으로 사용하는건 어떤가” 하는 궁금증이 생겼습니다.

아래 코드는 이러한 아이디어를 적용한 코드입니다.

// CounterStore.tsx
// state와 actions로 분리한 type 사용예시
type State = {
  count: number;
};

type Actions = {
  increment: () => void;
  decrement: () => void;
  resetCounterStore: () => void;
};

const initialState: State = {
  count: 0,
};

const useCounterStore = create<State & Actions>((set) => ({
  ...initialState,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  resetCounterStore: () => set(initialState),
}));

export default useCounterStore;

 

11. 파생데이터를 다루는 방법

store를 사용하다 보면 파생데이터를 다뤄야 하는 상황이 종종 생깁니다.

간단한 파생 데이터는 다음과 같이 get으로 가져오면 됩니다.

const useStore = create((set) => ({
  first: 'Daishi',
  last: 'Kato',
  get fullName() {
    return `${this.first} ${this.last}`;
  },
}));

하지만 computed 값 같은 경우는 골치가 아픕니다.

공식적으로 지원하는 게 없다 보니 별도의 라이브러리(zustand-middleware-computed-state)를 많이 사용하지만 아직 명확한 해결책은 없는 상황입니다. 이에 대한 논의는 아래 링크에서 확인할 수 있습니다.

https://github.com/pmndrs/zustand/issues/108

 

Correct approach to get derived values from state · Issue #108 · pmndrs/zustand

Hello, In my store, I have several methods that calculates same derived values from state. I am looking for an optimized way to achive that. Right now, I can do this: getDerivedValue: () => { retur...

github.com

 

 

마치며

지금까지 상태관리 라이브러리 Zustand의 간단한 사용법부터 Pro Tips까지 상세히 알아보았습니다. 

 

처음 Zustand를 알게 된건 입소문이나 위에서 언급한 장점들이 때문이 아닌 귀여운 마스코트 덕분이었습니다. 

마우스를 따라 무려 움직이기도 한답니다.

당시에는 상당히 마이너한 라이브러리라고 추측하고 Redux와 Recoil 학습에만 전념했었는데, 최근 스터디원의 권유로 스터디 발표준비를 하면서 Zustand의 매력에 빠져버린 듯 합니다. 

 

또한 Zustand가 가지는 매력적인 포인트중 하나는 Redux나 Recoil과 달리 Zustand와 Jotai는 일본인 개발자 '카토 다이시(Kato Daishi)'님을 포함한 Poimandres(Open sorce developer collective)가 선보인 라이브러리라는 점이었습니다. 개인 시작한 오픈소스 프로젝트가 프론트 엔드 생태계에서 가장 핫한 트렌트가 될 수 있는 개방된 개발 문화도 대단하다고 생각했습니다.  

 

처음 개발자를 목표로 했을 때 가졌던 목표중 하나는 '오픈소스 컨트리뷰터'로 활동하기 였습니다. 최근에는 사이드 프로젝트와 스터디 준비 등으로 오픈 소스는 이용만 하고 있었지만, Zustand를 공부하면서 오픈소스 컨트리뷰터 활동을 시작하고 싶다는 마음을 가지게 되었습니다. 

 

이후 React나 Next, Tailwind CSS 등과 관련한 포스팅도 꾸준히 업로드할 예정이지만, 적어도 2주에 한번씩은 Zustand 오픈 소스 컨트리뷰터가 되기 위한 여정을 다뤄보도록 하겠습니다. 오픈 소스 파일은 가끔 구경만 하는 정도여서 처음엔 많이 방황할 거 같지만 그것 또한 개발의 매력이라고 생각합니다. 나중에 기회가 된다면 함께 오픈 소스를 공부하는 스터디도 구성해보겠습니다.

 

앞으로 많은 여정을 함께하게 될 라이브러리에 대해 소개하는 첫 포스팅이다 보니 평소보다 포스팅이 길어졌습니다. 이후에 올라오는 Zustand 포스팅들은 이번 포스팅에서 배운 내용을 보다 쉽게 풀어내는 포스팅을 함께 진행하도록 노력하겠습니다. 

 

추가로 Zustand에 대해 자세히 알고 싶은 분들은 Zustand READMEZustand 공식문서 그리고 Kato Daishi의 개인블로그 를 한번 방문해보시는 것을 추천드리며 이번 포스팅을 마치겠습니다. 

 

참고

[상태관리] 내가 Zustand를 선택한 이유 (over the Recoil)

 

[상태관리] 내가 Zustand를 선택한 이유 (over the Recoil)

들어가며 편리함을 추구하는 프론트엔드 개발자 핸디입니다. 최근에 새롭게 진행한 사이드프로젝트에서는 recoil 대신에 zustand를 사용해 보았습니다. 그래서 이번 글에서는 zustand와 Recoil를 비교

all-dev-kang.tistory.com

zustand와 react query를 같이 사용하는 방법

 

zustand와 react query를 같이 사용하는 방법

zustand와 react query는 둘 다 여러 개의 전역 스토어를 지향합니다. 두 라이브러리를 같이 잘 사용하는 방법을 배워봅시다. 대부분의 아이디어는 아래 글에서 가져왔습니다. https://tkdodo.eu/blog/working-

itchallenger.tistory.com

[React] Zustand 사용하기

 

[React] Zustand 사용하기

안녕하세요. J4J입니다. 이번 포스팅은 Zustand 사용하는 방법에 대해 적어보는 시간을 가져보려고 합니다. Zustand란? zustand는 react에서 사용할 수 있는 상태 관리 라이브러리 중 하나로 redux와 같이 f

jforj.tistory.com