[상태관리] 내가 Zustand를 선택한 이유 (over the Recoil)
들어가며
편리함을 추구하는 프론트엔드 개발자 핸디입니다.
최근에 새롭게 진행한 사이드프로젝트에서는 recoil 대신에 zustand를 사용해 보았습니다.
그래서 이번 글에서는 zustand와 Recoil를 비교하고 zustand의 장점부터 사용법에 대해 설명하도록 하겠습니다
대상독자
- 간단하고 직관적인 상태관리 라이브러리에 대한 기초 지식이 필요한 개발자
- zustand와 recoil 사이에서 고민하는 개발자
본문 들어가기 전에 잠깐 살펴본 npm Trend입니다.
recoil의 0.7.6의 버전, 가장 많은 zustand의 star수와 압도적으로 작은 사이즈가 눈에 뜹니다.
그럼 이제 편리한 zustand의 세상으로 떠나보시죠.
Zustand
독일어로 ‘상태’라는 뜻을 가진 라이브러리입니다. Jotai를 만든 개발자(카토 다이시)가 만든 라이브러리이기도 합니다.
특징
다양한 라이브러리가 그렇듯 내세우고 있는 장점이 있는데 그중에 제가 느낀 장점 몇 개를 추려 살펴보도록 하겠습니다.
Simple
처음으로는 간단한 사용법입니다.
// 카운터 예시 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>
);
}
create로 store를 만들고 이를 훅으로 가져와서 사용하는 게 전부입니다.
함수의 이름부터 set, get으로 되는 네이밍부터 이게 사용법의 전부라고 해도 될 정도로 간단합니다.
러닝커브가 있는 다른 라이브러리와 달리 이게 끝입니다.
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>
);
}
zustand와 심플함은 비교할만한 수준의 recoil입니다. 하지만 recoil은 increment와 decrement 가 Counter 컴포넌트 내부에서 선언됩니다.
이렇게 사용하는 게 react의 useState와 비슷하다고 하여 recoil의 장점 중에 하나로 손꼽힙니다.
하지만 개발을 하다 보니 점점 store와 action이 분리되면서 불편함을 느꼈습니다.
그래서 저는 recoil를 사용하면서 아래와 같이 한 번 더 커스텀 훅으로 만들어 사용하고 있었습니다.
// useCounter로 counterState를 한번 랩핑함.
import { useRecoilState } from "recoil";
import { atom } from "recoil";
const counterState = atom({
key: "counter",
default: 0,
});
function useCounter() {
const [count, setCount] = useRecoilState(counterState);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
return {
count,
increment,
decrement,
};
}
function Counter() {
const { count, increment, decrement } = useCounter();
return (
<div>
<button onClick={decrement}>-</button>
{count}
<button onClick={increment}>+</button>
</div>
);
}
근데 이렇게 해야 할 짓을 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();
middleware (Immer와 persist)
zustand를 immer와 persist라는 내장 미들웨어가 있습니다.
immer를 이용해 복잡한 객체의 업데이트를 간단히 처리할 수 있습니다.
create 함수 안에 immer로 감싸기만 하면 됩니다. 이 부분도 매우 간단하죠.
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer' // npm install immer 필요
const useBeeStore = create(
immer((set) => ({ // <- 바로 요기 부분
bees: 0,
addBees: (by) =>
set((state) => {
state.bees += by
}),
}))
)
persist를 이용해 storage에 저장할 수 있습니다.
이전에는 새로고침시에 데이터 유지를 위해 recoil-persist라는 파생라이브러리르 사용하거나 별도의 로직을 만들어 저장하는 과정을 수행해야 했습니다.
하지만 다음과 같이 간단히 사용할 수 있었습니다.
로컬 스토리지뿐만 아니라 세션스토리지도 지원합니다.
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
const useFishStore = create(
persist(
(set, get) => ({
fishes: 0,
addAFish: () => set({ fishes: get().fishes + 1 }),
}),
{
name: 'food-storage', // unique name
storage: createJSONStorage(() => sessionStorage), // (optional) by default, 'localStorage' is used
}
)
)
그 밖에 타입스크립트 지원, Redux devtool 지원, react와 독립적인 동작등 매력적인 요소가 가득한 라이브러리입니다.
특히 이 중에 react와 독립적인 동작이 은근히 꿀 같은 기능입니다.
프로젝트를 진행하다 보면 react로 만들어진 라이브러리가 아닌 기존의 라이브러리를 사용해야 할 때가 종종 있습니다.
이 라이브러리와 상태관리를 위해서 로컬스토리지를 활용해서 간접적으로 하고 있었는데, 이 기능을 이용해서 로컬스토리지 대신에 직접적으로 상태관리가 가능하게 되었습니다.
Pro Tip
이번 단락에서는 zustand를 프로처럼 사용하는 방법에 대해서 알아보겠습니다.
원하는 것만 가져다 쓰기
es6를 사용하는 개발자형님들이라면 비구조화 할당 문법에 대해 많이 들어보시고 사용해 보셨을 겁니다.
당연히 zustand에서도 사용가능합니다.
예시로 increment, decrement가 필요한 상황이라고 가정하겠습니다.
import { shallow } from 'zustand/shallow'
// 일반 사용법
const { increment } = useCounter();
// 고수 사용법
const increment = useCounter(state => state.increment);
// 고수 사용법 2 shallow
const { increment, decrement } = useCounter((state) => ({
increment: state.increment,
decrement: state.decrement,
}),shallow);
위와 같이 사용할 수 있습니다.
이 두 개의 차이점은 비교로직 차이입니다.
react의 비교로직과 달리 state로 가져온 고수 사용법은 === 연산자를 쓰기 때문에 조금 더 효율적인 랜더링이 가능합니다. ( zustand가 내세우는 장점 중에 하나입니다 )
초기화하는 간편한 방법( 타입스크립트 )
이것은 제가 recoil의 reset에 대응되는 기능을 리펙토링 하기 위해 만들었는데요.
맨 처음 초기화하는 값을 별도로 선언해 주고 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;
initalState를 파일 내부에 선언하고 reset에서 값을 업데이트해 주는 형식입니다.
그래서 초기화함수를 활용할 수도 있고 코드를 분리함으로써 초기데이터를 읽기도 쉬워졌습니다.
타입스크립트와 함께 쓰기
바로 위의 예제에서는 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;
여러분은 어떤게 좋으신가요?
파생데이터를 다루는 방법
store를 사용하다 보면 파생데이터를 다뤄야 하는 상황이 종종 생깁니다.
간단한 파생 데이터는 다음과 같이 get으로 가져오면 됩니다.
const useStore = create((set) => ({
first: 'Daishi',
last: 'Kato',
get fullName() {
return `${this.first} ${this.last}`;
},
}));
하지만 computed 값 같은 경우는 골치가 아픕니다.
공식적으로 지원하는 게 없다 보니 별도의 라이브러리(zustand-middleware-computed-state)가 있습니다만 여기서는 어디까지나 기본 라이브러리만을 사용하는 것을 목적으로 합니다.
논의되는 내용도 다양합니다만 뭔가 만족스럽지 못했습니다.
그래서 저는 차라리 별도로 빼기로 결정하게 되었습니다.
예를 들어 useCounter 라면 useCounterDerived라는 네이밍컨벤션과 함께요.
import create from 'zustand';
import { useMemo } from "react";
// 선언
const useCounter = create((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 }))
}));
const useCounterDerived = () => {
const state = useCounter();
const compuntedCount = useMemo(() => {
// ... 생략
}, [state.count]);
return {
compuntedCount
}
};
zustand 스니펫 익스텐션 | handy-snippets
이 글을 작성한 당시 vsc에서 zustand 관련된 익스텐션을 검색해보면 아무것도 안뜨고 있었습니다.
그래서 기본적인 타입에 대한 익스텐션을 로컬로 만들어서 사용하고 있었는데, 팀원이 보고 공유해달라고 퍼블리싱을 해버렸습니다.
snippet를 만들때 공유할 목적이 아니었다보니 진짜 기본적인 것만 만들어서 사용하고 있었는데요.
시간이 지난만큼 다음 zustand 관련 블로깅을 진행할때는 최신버전으로 업데이트하여 공유해보도록 하겠습니다.
https://marketplace.visualstudio.com/items?itemName=handy-kang.handy-snippets
Recoil를 버린 이유
글을 작성하다 보니 recoil를 zustand로 바뀐 이유가 명확하지 않네요.
잦은 리랜더링
위에서도 간략히 언급했지만 Recoil의 철학처럼 각 컴포넌트에서 state action들이 있는 구조가 마음에 들지 않습니다. ( 이게 싫으면 왜 recoil..?)
그래서 별도의 훅을 만들어 사용했는데 훅을 만들어서 비구조화할당으로 state와 action를 가져다 썼는데 이게 매번 리랜더링을 일으키는 요소로 작용했습니다.
그래서 바꿨습니다.
중복된 키 에러
next프로젝트에서 recoil를 사용하면 터미널에 다음과 같은 에러를 자주 확인할 수 있습니다.
그리고 이 부분에 대한 예외처리 방법에 대한 논의가 있었습니다.
이런 부분도 마음에 들지 않았습니다.
단순히 사용했는데 라이브러리와 충돌을 일으키는데 고쳐지지 않는 오픈소스라니..
추가로 아직까지 버전 1이 되지 않는 미숙함도 손절하는데 큰 영향을 끼쳤습니다.
마치며
이로써 zustand의 기초 사용법부터 고수의 사용법에 대해서 살펴보았습니다.
그리고 recoil과 비교해서 어떤 편리함이 있는지 살펴보았습니다.
점점 다양하지고 발전 중인 상태관리라이브러리의 생태계에서 과연 zustand를 살아남을까요?
저 또한 궁금하네요.
끝.
다음글 읽기