
2~3년 전에 Zustand 한 방에 정리라는 글을 작성했었는데, 그 때와는 달라진 점이 많아 다시 정리해보고자 한다.
공식문서: https://zustand.docs.pmnd.rs
npm install zustand
yarn add zustand
pnpm add zustand
먼저 store를 생성하고 상태와 액션을 정의한다.
import { create } from 'zustand';
interface CounterState {
count: number;
increase: () => void;
decrease: () => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 }))
}));
생성한 store를 React 컴포넌트에서 가져와서 사용한다.
import React from 'react';
import { useCounterStore } from './store';
const Counter = () => {
const { count, increase, decrease } = useCounterStore();
return (
<div>
<h1>카운트: {count}</h1>
<button onClick={increase}>증가</button>
<button onClick={decrease}>감소</button>
</div>
);
};
export default Counter;
zustand에서는 create 또는 createStore 또는 createWithEqualityFn을 사용해서 store를 생성할 수 있다. 이 셋의 차이를 알아보자.
https://zustand.docs.pmnd.rs/apis/create
기본 store 생성 함수로, React 컴포넌트에서 직접 사용할 수 있는 훅을 생성한다.
const useCounterStore = create((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
}));
const count = useCounterStore((state) => state.count);
https://zustand.docs.pmnd.rs/apis/create-store
zustand 4.0 이후로 도입된 함수로, 독립적인 store 객체를 반환하여 React 바깥에서도 사용할 수 있도록 한다. zustand를 웹 워커나 서버 환경에서 활용할 수 있게 된다.
const counterStore = createStore((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
}));
// createStore는 useStore와 함께 사용 가능
import { useStore } from 'zustand';
const count = useStore(counterStore, (state) => state.count);
기본적으로 zustand는 상태가 변경되면 이전 상태와 현재 상태를 Object.js(얕은 비교)로 비교하고 달라진 점이 있다면 상태가 변경된 것으로 감지하고 리렌더링이 발생하게 된다. 하지만 객체 내부의 깊은 변경은 감지되지 않기 때문에 상태 내부에 중복된 객체가 있다면 createWithEqualityFn을 사용하여 커스텀한 비교 함수를 적용하는 것이 좋다.
import { createWithEqualityFn } from 'zustand/traditional';
const deepEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// deepEqual 대신 lodash의 isEqual 함수를 사용해도 됨! 뭐든 deep-equal이 가능한 함수면 상관없다.
const useStore = createWithEqualityFn((set) => ({
user: { name: 'Alice', age: 25 },
updateAge: (age) => set((state) => ({ user: { ...state.user, age } })),
}), deepEqual);
zustand에서는 store를 사용할 때 selector를 사용하는 것을 권장한다. selector를 사용하면 원하는 상태나 액션만을 가져올 수 있다.
// selector 사용 X
const { count } = useCounterStore();
const count = useStore(counterStore);
// selector 사용 O
const count = useCounterStore((state) => state.count);
const count = useStore(counterStore, (state) => state.count);
selector를 사용하지 않으면 useCounterStore가 전체 상태 객체를 가져오고 그 중에서 count를 구조분해할당하는 방식이기 때문에 count 외의 다른 상태 값이 변경되어도 리렌더링이 발생한다.
반면에 selector를 사용하면 count 값만 가져오게 도므로 불필요한 리렌더링을 줄일 수 있다.
zustand에서는 다양한 미들웨어를 지원한다.
https://zustand.docs.pmnd.rs/middlewares/combine
combine을 사용하면 TypeScript를 사용할 때 상태의 타입을 직접 작성하지 않고 추론하도록 할 수 있다.
// combine 사용 X
type State = {
count: number;
}
type Action = {
increase: () => void;
}
const useCounterStore = create<State & Action>( set => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
}))
// combine 사용 O
const useCounterStore = create(
combine(
{ count: 0 }, // initialState
set => { // action
increase: () => set((state) => ({ count: state.count + 1 }))
}
)
)
https://zustand.docs.pmnd.rs/middlewares/devtools
Redux Devtools Extension을 사용하게 해주어 상태를 시각적으로 확인할 수 있다.
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 }))
}))
);
https://zustand.docs.pmnd.rs/middlewares/immer
immer 미들웨어를 사용하면 상태를 업데이트할 때 자동으로 불변성을 유지하도록 관리해준다.
// immer 사용 X
const useStore = create(
(set) => ({
items: [],
addItem: (item) => set((state) => ({ arr: [...state.arr, item]})),
})
);
// immer 사용 O
const useStore = create(
immer((set) => ({
items: [],
addItem: (item) => set((state) => { state.arr.push(item); }),
}))
);
// immer 사용 X
const useStore = create(
(set) => ({
person: { name: 'John', age: 30 },
setName: (str) => set((state) => ({ person: {...state.person, name: str} })),
})
);
// immer 사용 O
const useStore = create(
immer((set) => ({
person: { name: 'John', age: 30 },
setName: (str) => set((state) => { state.person.name = str; }),
}))
);
https://zustand.docs.pmnd.rs/middlewares/persist
persist 미들웨어는 상태를 로컬 스토리지나 세션 스토리지에 저장하여 새로고침 후에도 유지할 수 있게 한다.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const usePersistedStore = create(
persist(
(set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })) }),
{ name: 'counter-storage' }
)
);
https://zustand.docs.pmnd.rs/middlewares/subscribe-with-selector
특정 상태의 변경을 감지하도록 구독하고 리스너를 등록할 수 있다.
const useCounterStore = create(
subscribeWithSelector(
(set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
})));
store에서 subscribe 함수를 실행할 수 있고, selector와 listener를 인자로 받는다. subscribe가 반환하는 함수를 실행하면 구독을 중단할 수 있다.
// 구독 시작
const unsubscribe = useCounterStore.subscribe(state => state.count, (state) => console.log(state.count));
// 구독 중단
unsubscribe();
shallow와 useShallow는 이름은 비슷하지만 아주 다른 역할을 한다.
https://zustand.docs.pmnd.rs/apis/shallow
shallow 함수는 두 인자를 받고, 두 값이 같은 지 다른 지 얕은 비교를 한 결과를 리턴한다.
따라서 중첩된 객체에는 사용할 수 없고, primitive 값이나 중첩이 없는 객체에만 사용할 수 있다.
shallow(1, 1) // true
shallow({ name: 'A'}, { name: 'A'}) // true
const person1 = {
name: {
firstname: 'A',
lastname: 'B'
}
}
const person2 = {
name: {
firstname: 'A',
lastname: 'B'
}
}
shallow(person1, person2) // false
https://zustand.docs.pmnd.rs/hooks/use-shallow
useShallow는 selector로 선택된 값들에 대해 얕은 비교를 진행하고 결과가 같으면 리렌더링이 발생하지 않도록 해주는 React 훅이다. 따라서 상태의 개수가 많아서 4번의 selector를 일일히 사용해주기 힘들 때 대신 사용해줄 수 있다.
// shallow 사용 X
const type = useStore(state => state.type);
const name = useStore(state => state.name);
const count = useStore(state => state.count);
// shallow 사용 O
const { type, name, count} = useStore(
useShallow(state => ({
type: state.type,
name: state.name,
count: state.count
}));
);