zustand 적용 - Slice Pattern

이동훈·2024년 8월 5일
post-thumbnail

zustand

1.개요

  • 독일어로 '상태'라는 뜻을 가진 라이브러리
  • 특정 라이브러리에 엮이지 않는다.
  • 한 개의 중앙에 집중된 형식의 스토어 구조를 활용하면서, 상태를 정의하고 사용하는 방법이 단순
  • Context API를 사용할 때와 달리 상태 변경 시 불필요한 리렌더링을 일으키지 않도록 제어하기 쉽다.
  • React에 직접적으로 의존하지 않기 때문에 자주 바뀌는 상태르 ㄹ직접 제어할 수 있느 ㄴ방법 제공
  • Hook을 기반으로 하는 편리한 API를 제공한다.
  • npm trends에서 다운로드 수를 비교하면 아직까지 redux가 월등하게 상위의 라이브러리로 사용되고 있다.
    zustand는 redux 다음으로 다운로드 수가 많으며, weekly downloads는 3위에 해당한다.

redux가 아닌 zustand를 선택하면서 장점은?

공식 문서에 따르면, 작고 빠르며 확장 가능한 베어본 상태 관리 솔루션이다.
Redux와 크게 차이나진 않지만, provider가 필요없다는 점과 action이 없어도 상태 변경이 가능하다는 점이 다르다.
가장 큰 장점은 Redux에 비해 코드 양이 현저히 줄어들고 Redux의 경우 보일러플레이트의 양이 많이 반면,
Zustand는 적은 코드 양으로 store의 생성 및 업데이트가 가능하다. 특히, 러닝커브도 낮은게 마음에 들었다.
Comparison

2.Installation

npm install zustand

3.Store 생성

import { create } from "zustand";

const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
  updateBears: (newBears) => set({ bears: newBears }),
}));

4.컴포넌트에서 Store bind 하기

function BearCounter() {
  const bears = useStore((state) => state.bears);
  return <h1>{bears} around here...</h1>;
}

function Controls() {
  const increasePopulation = useStore((state) => state.increasePopulation);
  return <button onClick={increasePopulation}>one up</button>;
}

Zustand는 개발자가 State의 타입을 선언하고, creat함수의 파라미터에 함수 형태로 State의 초기값과 state를 변경하는 함수를 선언하는 것 뿐이다.

Zustand는 자동으로 state를 생상하고 state가 변경될 때마다 React 컴포넌트를 업데이트 한다.

5.Slice Pattern

작은 단위의 Store로 Store를 분할

  • 많은 기능이 추가 될수록 스토어는 점점 커지고 유지 관리가 어려워 질 수가 있다.
  • 메인 스토어를 작은 개별 스토어로 분할하여 모듈화
    (공식문서 내용)
    Slice Pattern
// 공식문서 예제

interface BearSlice {
  bears: number;
  addBear: () => void;
  decrement: () => void;
  eatFish: () => void;
}

interface FishSlice {
  fishes: number;
  addFish: () => void;
}

interface SharedSlice {
  addBoth: () => void;
  getBoth: () => void;
}

const createBearSlice: StateCreator<
  BearSlice & FishSlice, // set/get 호출 store의 제너릭 타입
  [],
  [],
  BearSlice // createBearSlice의 제너릭 타입
> = (set) => ({
  bears: 0,
  addBear: () => set((state) => ({ bears: state.bears + 1 }), false, "addBear"),
  decrement: () => set((state) => ({ bears: state.bears - 1 })),
  eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
});

const createFishSlice: StateCreator<
  BearSlice & FishSlice,
  [],
  [],
  FishSlice
> = (set) => ({
  fishes: 0,
  addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
});

const createSharedSlice: StateCreator<
  BearSlice & FishSlice, // Store의 제너릭 타입
  [],
  [],
  SharedSlice // createSharedSlice의 제너릭 타입
> = (set, get) => ({
  addBoth: () => {
    // you can reuse previous methods
    get().addBear();
    get().addFish();
    // or do them from scratch
    // set((state) => ({ bears: state.bears + 1, fishes: state.fishes + 1 })
  },
  getBoth: () => get().bears + get().fishes,
});

Zustand Slice Pattern 적용

디렉토리 구조

react-zustand/
├── ...
│
├── src/
│   ├── components/
│   │   ├── Counter.tsx
│   │   ├── Todo.tsx
│   │   └── ...
│   │
│   ├── store/
│   │   ├── bearSlice.ts
│   │   ├── ...
│   │   ├── +[slice.ts]  // 추가 Slice 파일
│   │   ├── type.d.ts  // 중복 Slice 타입 재정의
│   │   └── useStore.tsx
│   │
│   └── App.tsx
└── ...

1. Slice의 경우 타입이 중복되는 코드가 있어 SlicePattern 공통 타입으로 재정의

type.d.ts

// type.d.ts

import { StateCreator } from "zustand";

/**
 *  하나의 store를 두고 상태마다 Slice를 만들어 병합 해주는 타입 정의
 * @type SlicePattern
 * @generic T: 상태의 타입
 * @generic S: Slice의 타입
 */

declare module "zustand" {
  type SlicePattern<T, S = T> = StateCreator<
    T,
    [["zustand/devtools", never]],
    [],
    S
  >;
}

2.Slice 생성

bearSlice.ts

// bearSlice.ts

import { SlicePattern } from "zustand";

export interface BearSlice {
  bears: number;
  addBear: () => void;
  decrement: () => void;
  // eatFish: () => void;
}

/**
 * 사용자 정의 Slice 생성
 * @param set
 * @generic BearSlice 상태 타입
 * @generic BearSlice Slice 타입
 */
export const createBearSlice: SlicePattern<BearSlice, BearSlice> = (set) => ({
  bears: 0,
  addBear: () =>
    set((state: BearSlice) => ({ bears: state.bears + 1 }), false, "addBear"),
  decrement: () => set((state: BearSlice) => ({ bears: state.bears - 1 })),
});

fishSlice.ts

// fishSlice.ts
import { SlicePattern } from "zustand";
import { BearSlice } from "./bearSlice";

export interface FishSlice {
  fishes: number;
  addFish: () => void;
}

export const createFishSlice: SlicePattern<BearSlice & FishSlice, FishSlice> = (
  set
) => ({
  fishes: 0,
  addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
});

shareSlice.ts

// shareSlice.ts

import { SlicePattern } from "zustand";
import { BearSlice } from "./bearSlice";
import { FishSlice } from "./fishSlice";

export interface SharedSlice {
  addBoth: () => void;
  getBoth: () => void;
}

export const createSharedSlice: SlicePattern<
  BearSlice & FishSlice,
  SharedSlice
> = (set, get) => ({
  addBoth: () => {
    // you can reuse previous methods

    get().addBear();
    get().addFish();
    // or do them from scratch
    // set((state) => ({ bears: state.bears + 1, fishes: state.fishes + 1 })
  },
  getBoth: () => get().bears + get().fishes,
});

3.Store 생성해 slice 병합

useStore.tsx

// useStore.tsx

import { create, SlicePattern, StateCreator } from "zustand";
import { devtools } from "zustand/middleware";
import { createBearSlice, BearSlice } from "./bearSlice";
import { createFishSlice, FishSlice } from "./fishSlice";
import { createSharedSlice, SharedSlice } from "./sharedSlice";

/**
 * Slice Store 타입 정의
 */
type BoundStoreType = BearSlice & FishSlice & SharedSlice;

const useBoundStore = create<BoundStoreType>()(
  devtools(
    (...a) => ({
      ...createBearSlice(...a),
      ...createFishSlice(...a),
      ...createSharedSlice(...a),
    }),
    { name: "MyZustandStore" }
  )
);

export default useBoundStore;

4.Selector

Counter.tsx

import React from "react";
import useStore from "../stores/useStore";
import { BearSlice } from "../stores/bearSlice";
import { FishSlice } from "../stores/fishSlice";

const Counter: React.FC = () => {
  const { bears, addBear, decrement } = useStore((state: BearSlice) => state);

  const { fishes } = useStore((state: FishSlice) => state);

  return (
    <div>
      <h2>Counter</h2>
      <p>{bears}</p>
      <p>{fishes}</p>
      <button onClick={() => addBear()}>Increment</button>
      <button onClick={() => decrement()}>Decrement</button>
      <button onClick={() => decrement()}>Decrement</button>
    </div>
  );
};

export default Counter;

6.사용 후기

  • 기존의 사용했던 Redux와 비교 했을 때, 액션, 액션함수, 리듀서, 타입 정의 등 하나의 디렉토리로 관심사를 모아 CombineRedux 했던 점을 Zustand는 따로 정의 할 필요 없이 장황한 코드가 확연히 줄어들었다.

  • 불변 상태를 관리하기 위한 immer 라이브러리가 적용 했을 때의 이점이 있을지 고민이 필요.

    immer 라이브러리란?

    immer는 JavaScript 애플리케이션에서 불변 상태를 관리하기 위해 설계된 라이브러리입니다.
    immer는 상태를 불변하게 유지하면서도, 가변 상태처럼 간단하고 직관적으로 업데이트할 수 있게 해줍니다
    [출처: ChatGPT]

  • 공식 문서가 깔끔하면서도 예제가 자세해 빠르게 학습할 수 있었다.

  • 전역 상태에 Provider를 제거할 수 있어 상대적으로 향후 마이그레이션이 원활할 것이다라는 기대..

  • devtools는 크롬 확장 프로그램인 Redux Devtools를 이용해 상태를 디버깅 할 수 있게 해주는데, store가 다르면 상태가 호출되었을 때 해당 store만 디버깅하면 된다.

참고

https://docs.pmnd.rs/zustand/guides/slices-pattern

https://docs.pmnd.rs/zustand/guides/typescript#middlewares-and-their-mutators-reference

https://velog.io/@real-bird/Zustand-%EA%B3%B5%EC%8B%9D-%EB%AC%B8%EC%84%9C%EB%A7%8C-%EB%B3%B4%EA%B3%A0-Zustand-%EC%A0%81%EC%9A%A9%ED%95%B4-%EB%B3%B4%EA%B8%B0#4-1-devtools

profile
한계를 부시는 프론트개발자

0개의 댓글