[소소한 개발 일지] NavigationSplitView에서 사이드바 활성화 여부 확인하기(SwiftUI)
React의 전역 상태 라이브러리 2탄, Zustand와 Jotai를 간단하게 구현해보며 알아보자
2024-12-12
Explanation
오랜만에 찾아온 포스팅 시간!!
오늘은 React의 전역 상태 관리 라이브러리 알아보기 2탄!으로 Zustand와 Jotai를 간단하게 구현해 보면서 그 특징에 대해 알아보는 시간을 가져보려 합니다!
이 글에 사용되는 샘플 프로젝트는 아래의 리포지토리에서 확인 하실 수 있습니다.
링크: https://github.com/falsy/blog-post-example/tree/main/zustand-jotai
Zustand는 Context API를 사용하지 않고 클로저 기반으로 상태를 캡슐화해서 관리하는데요. 저는 간단하게 타입스크립트로 클래스를 사용해서 만들어 보았습니다.
private 접근 제한자는 사실 클로저로 컴파일되지 않지만.. 간단한 예시를 위해 넘어가도록 하겠습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import { useSyncExternalStore } from "react" type Listener<T> = (state: T) => void export default class Justand<T> { private state: T private listeners = new Set<Listener<T>() private getState() { return this.state } private subscribe(listener: Listener<T>) { this.listeners.add(listener) return () => this.listeners.delete(listener) } constructor(initialState: T) { this.state = initialState } setState(updater: T | ((state: T) => T)) { this.state = typeof updater === "function" ? (updater as (state: T) => T)(this.state) : updater this.listeners.forEach((listener) => listener(this.state)) } useStore<U>(selector: (state: T) => U): U { return useSyncExternalStore( (listener) => this.subscribe(listener), () => selector(this.getState()) ) } } |
이제 간단하게 카운터 훅을 만들어서 사용해볼까요?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import Justand from "../libs/justand" type CountState = { count: number } const store = new Justand<CountState>({ count: 0 }) export const useCounter = () => { const count: number = store.useStore((state) => state.count) const setCount = (newCount: number | ((state: number) => number)) => { const newState = typeof newCount === "function" ? (newCount as (state: number) => number)(count) : newCount return store.setState({ count: newState }) } return [count, setCount] as const } |
짜잔! 위와 같이 간단하게 Zustand의 짝퉁 Justand 라는 전역 상태 관리 로직을 구현해 봤는데요. 위와 같이 Context API를 사용하지 않고 클로저와 구독을 통해서, 그리고 “useSyncExternalStore”를 사용해서 React와 연결하여 구현하였습니다.
그리고 select을 사용해서 특정 상태의 변화에만 해당 훅을 사용하는 컴포넌트만 리렌더링이 동작하게 된답니다. 간단하게 추가로 예를 들어보면, 아래와 같이 count에 추가로 step 이라는 상태 값이 있고 useStepper 라는 훅이 추가로 있다고 가정했을 때, step의 상태 값이 변경되어도 useCounter 훅을 사용하는 컴포넌트는 리렌더링 되지 않는답니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
import Justand from "../libs/justand" type CountState = { count: number, step: number } const store = new Justand<CountState>({ count: 0, step: 0 }) export const useCounter = () => { const count: number = store.useStore((state) => state.count) const setCount = (newCount: number | ((state: number) => number)) => { const newState = typeof newCount === "function" ? (newCount as (state: number) => number)(count) : newCount return store.setState({ count: newState }) } return [count, setCount] as const } export const useStepper = () => { const step: number = store.useStore((state) => state.step) const setStep = (newStep: number | ((state: number) => number)) => { const newState = typeof newStep === "function" ? (newStep as (state: number) => number)(step) : newStep return store.setState({ step: newState }) } return [step, setStep] as const } |
Zustand가 약간 Redux의 스타일이라면, Jotai는 Recoil 스타일인데요. Zustand가 store 단위로 상태를 관리한다면 Jotai는 atom 단위로 상태를 관리한답니다.
마찬가지로 간단하게 구현해 본다면,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
import { useSyncExternalStore } from "react" type Listener<T> = (value: T) => void class Atom<T> { private value: T private listeners = new Set<Listener<T>() constructor(initialValue: T) { this.value = initialValue } get() { return this.value } set(newValue: T | ((prev: T) => T)) { this.value = typeof newValue === "function" ? (newValue as (prev: T) => T)(this.value) : newValue this.listeners.forEach((listener) => listener(this.value)) } subscribe(listener: Listener<T>) { this.listeners.add(listener) return () => this.listeners.delete(listener) } } export const atom = <T>(initialValue: T) => new Atom<T>(initialValue) export const useAtom = <T>( atom: Atom<T> ): [T, (value: T | ((prev: T) => T)) => void] => { const value = useSyncExternalStore( (listener) => atom.subscribe(listener), () => atom.get() ) const setValue = (newValue: T | ((prev: T) => T)) => atom.set(newValue) return [value, setValue] } |
짜잔! 위와 같이 Jotai의 짝퉁 Zotai를 만들어서 atom과 useAtom을 구현해보았습니다.
위 atom을 사용해서 step을 구현한다면 아래와 같이 사용할 수 있답니다.
1 2 3 4 |
// stoms/stepAtom.ts import { atom } from "../libs/zutai" export const stepAtom = atom(0) |
1 2 3 4 5 6 7 8 9 |
import { useAtom } from "../libs/zutai" import { stepAtom } from "../atoms/stepAtom" export default function ZotaiComponent() { const [step, setStep] = useAtom(stepAtom) ... } |
이렇듯 Zustand는 스토어의 setState를 통해서 상태를 변경하고 Jotai는 원자(atom)에 직접 업데이트하는 방식으로 동작한답니다.
그리고 아주아주 간단하게 구현하다보니 Provider를 구현하지 않았는데요. 위 코드처럼 Jotai도 역시 상태에 대해서 Context API를 사용하지 않고 클로저 기반으로 상태를 관리하지만, Context API를 사용해서 트리 구조의 컴포넌트에서 특정 서브 트리 범위의 컴포넌트만 Provider를 사용해서 같은 원자(atom)라도 독립적으로 관리할 수 있답니다.
이제 슬슬.. 마무리 단계로..
Zustand와 Jotai의 공통점은! (같은 곳에서 만든다?! (https://github.com/pmndrs))
아마도 가장 큰 공통점은 클로저 기반으로 상태를 관리한다는 부분이 가장 큰 공통점일 것 같아요. 그리고 둘 모두 상태 변경시 해당 상태를 구독한 컴포넌트만 렌더링 된다는 점도 있을 것 같아요.
그리고 차이점이라면 아까 잠깐 언급했던 것처럼 Zustand는 Flux 처럼 Store라는 중앙 집중형으로 상태를 관리하며 Jotai는 Atomic 처럼 분산형으로 여러 작은 원자(atom)단위로 상태를 관리한다는 차이가 가장 큰 차이일 것 같아요.
그리고 이 차이로 Zustand를 사용하여 개발한다면 top-down 방식으로 상태를 관리하며 개발하게 될 것 같고, Jotai를 사용하여 개발한다면 bottom-up 방식으로 상태를 관리하며 개발하게 될 것 같아요.
반대로 생각하면, 중앙의 전역의 상태를 중심으로 개발되는 프로젝트라면 Zustand가 적합하고, 독립적인 작은 상태들과 그 조합을 통해 개발되는 프로젝트라면 Jotai가 적합하다고 볼 수 있겠네요.
그리고 Zustand는 중앙에 스토어를 중심으로 전체 상태를 관리하기 때문에 devtool 같은 도구나 디버깅이 조금 더 수월할 수 있으며, Jotai는 상태를 작은 단위로 자유롭고 유연성있게 사용할 수 있다는 점과 Provider를 사용해서 부분적으로 독립적인 상태를 관리할 수 있다는 장점이 있습니다.
아! 그리고 Zustand와 Jotai는 모두 React에서 useSyncExternalStore를 사용해 상태를 연결하지만, Jotai는 atom의 로직이 React와 강하게 결합되어 있어 React에 의존적이며, Zustand는 코어 로직이 React와 독립적으로 동작할 수 있다는 차이가 있습니다.
보통 “이러이러한 이유로 저라면 이것을 사용하겠다!”라고 마무리하는 게 깔끔한데.. 둘의 특징이 분명하고, 또 러닝 커브가 큰 기술도 아니기 때문에, 프로젝트에 따라 선택해서 사용하면 좋을 것 같습니다!
다음번엔 실제로 프로젝트에서 두 라이브러리 모두 사용해 본 후에 3탄으로 다시 정리해서 포스팅하도록 하겠습니다!