안녕하세요. J4J입니다.
이번 포스팅은 Zustand 사용하는 방법에 대해 적어보는 시간을 가져보려고 합니다.
Zustand란?
zustand는 react에서 사용할 수 있는 상태 관리 라이브러리 중 하나로 redux와 같이 flux 패턴을 활용하는 기술 중 하나입니다.
react에서 활용되는 상태 관리 라이브러리들은 여러 개가 있는데 대표적으로 redux, zustand, jotai, recoil 등이 존재하며 최근 npm 트렌드를 확인해 보면 redux 다음으로 가장 많이 활용되는 라이브러리라고 볼 수 있습니다.
react를 처음 배울 때 최초로 사용해봤던 상태 관리 라이브러리는 redux인데 전역으로 데이터를 관리하기 위해 이렇게 까지 코드가 장황해야 하는 생각이 들었던 기억이 있습니다.
그래서 facebook에서 개발한 recoil이 등장하면서 recoil을 지속적으로 사용해 왔고 사용성 또한 많이 만족해오고 있었는데, zustand에 대한 관심이 점점 더 높아지는 것으로 보여 어떤 차이점이 있는지를 확인하고 싶었습니다.
zustand를 사용해봤을 때 recoil과의 차이점은 다음의 것들이 있었습니다.
- key값을 정의하지 않음
- store 사용을 위한 작성되는 코드의 길이가 줄어듬
- redux devtools를 지원
- persist와 같은 부가적인 미들웨어 기능을 지원
- provider를 사용하지 않음
이 외에도 다른 차이점들이 더 존재하지만 위의 내용들은 사용하면서 괜찮다고 생각한 것들입니다.
이런 차이점들을 볼 수 있었기에 recoil에서 다른 상태 관리로 넘어갈 필요가 없다고 생각했던 것이 zustand로 갈아타도 괜찮을 것 같다는 생각으로 바뀔 수 있었습니다.
기본 설정
zustand를 이용하여 값을 증가시키는 store를 간단히 만들어 보겠습니다.
[ 1. 패키지 설치 ]
$ npm install 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;
[ 3. store 사용하는 파일 ]
import React from 'react';
import useNumberBaseStore from '../modules/zustand/base';
const Base = () => {
// 한번에 가져오는 경우
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 Base;
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입니다.
한번 더 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만 리렌더링이 발생되는 것을 볼 수 있습니다.
이처럼 array, object 등에 shallow를 적용하면 불필요한 렌더링을 줄일 수 있기 때문에 적극적으로 사용해야 된다고 생각이 드는 부분입니다.
State 덮어쓰기
위에서 작성된 store 코드를 자세히 보신 분들은 아시겠지만 set을 이용하여 store state값을 변경할 때 변경하고자 하는 state 값만 정의를 해주면 이전 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;
Persist Middleware
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의 초기 값으로 설정하기 때문에 페이지 이동에 의해 데이터가 초기화되는 현상을 막아줄 수 있습니다.
해당 기능도 적극적으로 활용해 보면 효율적으로 사용될 수 있을 것으로 보입니다.
Devtools Middleware
또 다른 zustand middleware로 redux devtools를 활용해 볼 수 있습니다.
redux를 이용할 때와 동일한 결과를 만들어주는 middleware이며 persist처럼 store에 설정만 해주면 바로 활용해볼 수 있습니다.
적용 방법은 다음과 같습니다.
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface UseNumberDevtoolsStore {
numberA: number;
numberB: number;
increaseNumberA: () => void;
increaseNumberB: (value: number) => void;
}
const useNumberDevtoolsStore = create<UseNumberDevtoolsStore>()(
devtools((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 useNumberDevtoolsStore;
위와 같이 코드를 구성하고 해당 store를 사용하는 page에 접근하여 store state 값들을 변경해 보면 다음과 같이 redux devtools에 history가 남는 것을 확인해 볼 수 있습니다.
번외
위의 내용들만 사용해도 많은 기능들을 적용해 볼 수 있지만 이 외에도 zustand에서 제공해주고 있는 기능들에는 비동기 처리, Immer, subscribe 등의 여러 가지가 더 존재하고 있습니다.
Zustand 공식 문서를 한번씩 확인하셔서 각자에게 도움이 더 될 것이라고 생각되는 기능들이 있는지 둘러보는 것을 추천드립니다.
이상으로 Zustand 사용하는 방법에 대해 간단하게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'SPA > React' 카테고리의 다른 글
[React] Jest로 테스트할 때 비동기 처리하기 (0) | 2023.10.05 |
---|---|
[React] Vite 사용하기 (3) | 2023.07.11 |
[React] Tailwind 사용하기 (3) - 커스텀(Custom) 하기 (0) | 2023.05.24 |
[React] Tailwind 사용하기 (2) - StyledComponents와 CSS 적용 비교하기 (0) | 2023.05.22 |
[React] Tailwind 사용하기 (1) - 개념 및 설정 (0) | 2023.05.17 |
댓글