[React🌀] 차세대 상태관리 라이브러리, Jotai VS Zustand ⭐ (Feat. Recoil)
들어가며
프론트엔드 분야는 변화가 정말 빠르고 쉴새없이 발전하고 있는 것 같습니다. 하던 것만 열심히하면 어느새 뒤쳐지고 있는 자신을 발견할 수 있습니다… 😭 그 중에서도 가장 우리를 까다롭게 만드는 분야가 있죠, 바로 상태관리 (state management) 입니다.
리액트와 Next JS 등 프레임워크들이 여러 문제상황들을 해결해주었다고 하지만 상태관리에 대해서는 여전히 의문입니다. 코드가 길어지고, 무언가 모를 불편함이 항상 존재해왔죠. Redux 나 Mobx, 거기에 더 나아가서는 Redux 사용을 간소화시킨 Redux toolkit 또한 등장하지만 아직도 이 부분은 발전할 길이 많이 남았다고 생각됩니다.
그 중에 최근들어 점점 주목받고있는 2가지 라이브러리를 소개하고자 합니다!
바로 Jotai 와 Zustand 입니다.
왜 Jotai VS Zustand ? Recoil은 어디갔을까?
facebook 에서 개발한 recoil 또한 주요 선택지 중에 하나였습니다.
하지만.. 저는 아래와 같은 이유로 zustand와 recoil이 더 끌리더군요.
- recoil 는 Facebook 개발진들이 추친하고있음에도 불구하고 매우 느리게 commit 이루어지는 중이며, 그에 반해 Jotai 와 Zustand 는 굉장히 활발하게 업데이트가 이루어지고 있다.
- Recoil : v0.5.1
- Jotai : v1.5.0
- Zustand : v3.6.8
- Jotai 의 atom 은 Recoil과 다르게 key가 따로 필요없다. (보일러 플레이트 코드가 미세하게 더 줄어든다.)
- recoil 은 JavaScript 로 작성된 반면 Jotail 와 Zustand는 100% TS 로 작성되었다.
- recoil 에 영감을 받아 만들어진 것이 Jotai 이다. (사실 영감이 받았다는 것이 더 발전했다는 뜻은 아닙니다. 단순히 새로운 것을 더 선호하는 제 취향이 들어갔습니다 ㅎㅎ)
저 같은 경우는 위와 같은 이유들로 Recoil 이 아닌 Jotai 와 Zustand 중에 고민하게 되었는데요, recoil이 나쁘다는 뜻은 아닙니다. 오히려 레퍼런스를 찾을 수 있는 풀이 넓다는 면과 안정성의 측면에서는 recoil이 더 좋을 수도 있겠죠. 하지만 새 것을 선호하는 제 취향이 섞여 저는 Jotai 와 Zustand에 더 꽂히게 되더군요.
제가 jotai 와 zustand 에 대해 리서치하면서 정리했던 내용을 공유하고자 합니다.
(이전에 정리했던 내용이다보니까 편의상 구어체 사용한점 양해 바랍니다 😄 )
사용방법에 대해서 정리했다기 보다는 주요 특징들에 대해서 정리해보았구요, 두 라이브러리를 비교해보았습니다. 사실 사용방법을 익히는 것은 공식문서를 보시는게 더 좋을 거에요. 두 라이브러리 모두 가독성 좋고 친절하게 정리가 되어있습니다.
Jotai
- Jotai 는 Context의 re-rendering 문제를 해결하기 위해 만들어진 React 특화 상태관리 라이브러리
- Recoil 에서 영감을 받아, atomic 한 상태관리 방식으로 구성됨 (bottom-up 방식)
- 깃허브 Star 6.8k⭐ (2022-01-09 기준)
장점은?
- 기본적으로 re-rendering 문제를 줄여주고, selectAtom, splitAtom 과 같은 re-rendering 을 줄이기 위한 유틸들도 지원한다.
- 보일러 플레이트 코드가 redux에 비하면 현저하게 줄어든다.
- 앞으로 React 의 주요 feature일 Suspense(Concurrent mode)를 적용하는데에 적합하게 설계되었다.
- Jotai 가 강조하는 두가지 특징
- Primitive: 리액트 기본 state 함수인 useState 와 유사한 인터페이스
- Flexible: atom들끼리 서로 결합 및 상태에 관여할 수 있고, 다른 라이브러리들과 원할한 결합을 지원한다.
주요 특징
atom
- 기본적으로 atom 단위로 상태를 관리하며 atom 을 이용해서 state를 생성한다.
- useState 와 같은 인터페이스인 useAtom 을 이용하여 사용한다.
- useAtom 외에도 atom을 사용할 다른 유틸 훅들을 제공한다.
- 실제 값을 들고 있는 것은 아니고 일종의 atom ‘정의’ 이다.
- 인자값에 initialValue를 넣어주면 된다.
- recoil 과 다르게 atom 에 키값을 넣어줄 필요가 없어서 코드길이가 줄어든다.
import { atom } from 'jotai'
const anAtom = atom(10)
const Component = () => { const [value, updateValue] = useAtom(anAtom) ... }
atom 함수 이용 방식
atom(InitialValue)
로 선언
- 기본적인 useState 처럼 사용됨
const countAtom = atom(0)
const [count, setCount] = useAtom(countAtom)
atom(initialValue, (get, set, args) => {} )
형태로 선언
- set함수를 커스텀 가능
여기서의 get
함수는 기존의 atom 값을 읽을 수 있는 함수이고,
(get, set, args) => { get(otherAtom) // otherAtom값을 불러옴}
set함수는 기존의 atom 의 값을 변화시킬 수 있는 함수이다.
(get, set, args) => { set(aAtom, get(bAtom)) // bAtom값을 불러와서 aAtom 의 값을 bAtom값으로 적재}
사용 예시
const decrementCountAtom = atom(
(get) => get(countAtom),
(get, set, _arg) => set(countAtom, get(countAtom) - 1), // _arg 는 이후 set함수의 인자값을 의미한다. (현재 이 예시에선 사용되지 않음)
)
function Counter() {
const [count, decrement] = useAtom(decrementCountAtom)
return (
<h1>
{count}
<button onClick={decrement}>Decrease</button>...
Provider와 Scope
jotai 는 기본적으로 Provider를 사용하지 않아도 된다. Provider가 없더라도 atom을 선언할 때 설정된 기본 값을 가진 atom 을 전역적으로 사용할 수 있다.
이는 jotai 가 내부적으로 react 의 context API를 이용하기 때문에 가능한 것인데, context API 도 사실 Provider 없이 사용 가능하다. 다만, Provier가 없다면 하위 컴퍼넌트들에서 구독하는 context들이 리렌더링이 되지 않을 것이다. jotai 는 이것을 가능하게 만들었으며, Provider 하위 컴퍼넌트 전체가 리렌더링되는 (혹은 메모이제이션을 추가적으로 해주어야하는) 불편함을 개선했다고 보면 된다.
Provider 하위에서 context를 구독하는 모든 컴포넌트는 Provider의 value prop가 바뀔 때마다 다시 렌더링 됩니다. Provider로부터 하위 consumer(.contextType과 useContext 을 포함한)으로의 전파는 shouldComponentUpdate 메서드가 적용되지 않으므로, 상위 컴포넌트가 업데이트를 건너 뛰더라도 consumer가 업데이트됩니다.
하지만 Jotai 에서도 Provider를 사용한다면 아래와 같은 몇가지 기능을 이용할 수 있다.
Initial Value
atom 에 initialValues 들을 넣어준다.
const TestRoot = () => (
<Provider
initialValues={[
[atom1, 1],
[atom2, 'b'],
]}
>
<Component />
</Provider>
)
Scope
jotai 는 내부적으로 react 의 context API를 이용하기 떄문에 context API 의 provider와 유사하나, jotai의 Provider에는 scope 를 줄 수 있다.
const myScope = Symbol()
const anAtom = atom('')
const LibraryComponent = () => {
const [value, setValue] = useAtom(anAtom, myScope)
// ...
}
const LibraryRoot = ({ children }) => (
<Provider scope={myScope}>
{children}
</Provider>
)
- scope 별 Provider 들은 같은 atom config 에 대해서도 다른 값을 가지게 된다.
- 따라서, 같은 atom 이라도 scope를 어떻게 주냐에 따라서, 값이 각각 고유하게 동작한다.
- scope 값은 unique 할 수 있도록 Symbol() 로 하는 것이 권장된다.
리렌더링 최소화
- 대용량 오브젝트나 array 형태의 state에 대해 변경사항이 생겼을 때, 일반적으로 해당 state를 사용하는 모든 컴퍼넌트에 대해서 리렌더링이 발생하는데 그런 문제점을 optics라는 외부 라이브러리를 integration 해서 해결했다.
- https://jotai.org/docs/advanced-recipes/large-objects
selectAtom
큰 object형 atom의 read-only 용의 atom을 생성한다.
예시
큰 object 형태의 atom
const defaultPerson = {
name: {
first: 'Jane',
last: 'Doe',
},
birth: {
year: 2000,
month: 'Jan',
day: 1,
time: {
hour: 1,
minute: 1,
},
},
}
const personAtom = atom(defaultPerson)
selectAtom 활용
const nameAtom = selectAtom(personAtom, (person) => person.name)
- personAtom 의 name 에 해당하는 개별 atom
- nameAtom 은 person.name 이 바뀌었을때만 컴퍼넌트의 리렌더링을 유발시킨다. (예를들어 birth 가 바뀌었을 때는 리렌더 x)
deepEquals 옵션
const birthAtom = selectAtom(personAtom, (person) => person.birth, deepEquals)
- 위의 케이스는 오브젝트의 참조값을 가지고 비교를 했다면
- deepEquals 옵션을 이용하면 직접 타고들어가 값 자체를 비교해서 리렌더링 여부를 결정하는 듯 하다.
focusAtom
focusAtom 은 selectAtom 와 매우 유사하나, write 도 가능한 atom을 만들고 싶을 경우 사용된다.
예시
import { atom } from 'jotai'
import { focusAtom } from 'jotai/optics'
const objectAtom = atom({ a: 5, b: 10 })
const aAtom = focusAtom(objectAtom, (optic) => optic.prop('a'))
const bAtom = focusAtom(objectAtom, (optic) => optic.prop('b'))
const Controls = () => {
const [a, setA] = useAtom(aAtom)
const [b, setB] = useAtom(bAtom)
return (
<div>
<span>Value of a: {a}</span>
<span>Value of b: {b}</span>
<button onClick={() => setA((oldA) => oldA + 1)}>Increment a</button>
<button onClick={() => setB((oldB) => oldB + 1)}>Increment b</button>
</div>
)
}
splitAtom
큰 배열의 형태의 Atom 일 경우, 각 요소들을 개별적으로 atom으로 만들어준다.
예시
import { splitAtom } from 'jotai/utils'
const peopleAtomsAtom = splitAtom(peopleAtom)
const People = () => {
const [peopleAtoms] = useAtom(peopleAtomsAtom)
return (
<div>
{peopleAtoms.map((personAtom) => (
<Person personAtom={personAtom} key={`${personAtom}`} />
))}
</div>
)
}
- 각 Person 컴퍼넌트에 전달된 값들은 서로다른 personAtom 으로 만들어진다.
- 따라서, 다른 atom이 변경된다 하더라도 서로 리렌더링을 일으키지 않는다.
Async
async set
set함수에서 async 를 이용하는 방법은 매우 쉽다. 그냥 set 함수에 async 태그를 붙여주면 끝이다.
예시
const fetchCountAtom = atom(
(get) => get(countAtom),
async (_get, set, url) => {
const response = await fetch(url)
set(countAtom, (await response.json()).count)
}
)
function Controls() {
const [count, compute] = useAtom(fetchCountAtom)
return <button onClick={() => compute("http://count.host.com")}>compute</button>
async get
Jotai 는 Suspense(React Concurrent mode) 를 지원하는 상태관리 라이브러리이다.
비동기적으로 값을 받아오는 atom을 이용하고 싶다면 , 적어도 하나의 Suspense 태그로 감싸고 있어야 한다.
const App = () => (
<Provider>
<Suspense fallback="Loading...">
<Layout />
</Suspense>
</Provider>
)
그 후 get 함수에 async 태그를 붙인다면, 그 함수가 resolve 되기 전까지는 결과값이 suspense 에 감싸지게 되고 re-render가 suspend된다.
예시
const countAtom = atom(1)
const asyncCountAtom = atom(async (get) => get(countAtom) * 2)
Suspense 없이도 비동기 get 을 사용하는 방법을 지원한다.
- https://jotai.org/docs/guides/no-suspense
기타
- Suspense 사용예시 : https://codesandbox.io/s/github/pmndrs/jotai/tree/main/examples/hacker_news
Flexible
- 다른 상태 관리 라이브러리들과의 Integration을 제공
Redux 와 intergration
import { useAtom } from 'jotai'
import { atomWithStore } from 'jotai/redux'
import { createStore } from 'redux'
const initialState = { count: 0 }
const reducer = (state = initialState, action: { type: 'INC' }) => {
if (action.type === 'INC') {
return { ...state, count: state.count + 1 }
}
return state
}
const store = createStore(reducer)
const storeAtom = atomWithStore(store)
const Counter: React.FC = () => {
const [state, dispatch] = useAtom(storeAtom)
return (
<>
count: {state.count}
<button onClick={() => dispatch({ type: 'INC' })}>button</button>
</>
)
}
React-query와 intergration
import { useAtom } from 'jotai'
import { atomWithQuery } from 'jotai/query'
const idAtom = atom(1)
const userAtom = atomWithQuery((get) => ({
queryKey: ['users', get(idAtom)],
queryFn: async ({ queryKey: [, id] }) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
return res.json()
},
}))
const UserData = () => {
const [data] = useAtom(userAtom)
return <div>{JSON.stringify(data)}</div>
}
기타
- SSR 지원
- Next JS + SSR 예시 (
useHydrateAtoms
를 이용)
- Next JS + SSR 예시 (
- http://blog.hwahae.co.kr/all/tech/tech-tech/6099/ 이 블로그가 정말 잘 설명해주고 있으니 관심이 있으면 읽어보면 좋다!
Zustand
- 보일러 플레이트가 최소화된 상태관리 solution. Store 형태임에도 굉장히 간단하게 상태관리 구성이 가능하다.
- 깃허브 Star 12.7k⭐ (2022-01-09)
장점
- store 구현 방식 및 변경 방식이 간단해 보일러플레이트 코드가 매우 줄어든다.
- 익히기가 굉장히 쉽다. 아래 주요 특징의 예시를 보면 기본적인 사용법은 사실상 모두 익힌것이다. (그만큼 공식문서도굉장히 짧다 !)
- Provider로 감쌀 필요가 없다.
- context 방식보다 리렌더링이 줄어든다.
주요 특징
간단한 사용법
- store 생성 :
create
import create from 'zustand'
const useStore = create(set => ({
bears: 0,
fishies: {},
increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
fetch: async pond => {
const response = await fetch(pond)
set({ fishies: await response.json() })
}
}))
// 객체 안에 state로 사용할 키와 값을 담아주고, create 함수로 묶어준다.
// 액션 또한 store의 키 값 형태로 담기며, sync action도 포함 가능하다.
- 사용 :
useStore
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>
}
- 액션 내부에서 다른 state 참조
const useStore = create((set, get) => ({
sound: "grunt",
action: () => {
const sound = get().sound
// ...
}
})
상태 구독(subscribe) 가능
import { subscribeWithSelector } from 'zustand/middleware'
const useStore = create(subscribeWithSelector(() => ({ paw: true, snout: true, fur: true })))
// Listening to selected changes, in this case when "paw" changes
const unsub2 = useStore.subscribe(state => state.paw, console.log)
// Subscribe also exposes the previous value
const unsub3 = useStore.subscribe(state => state.paw, (paw, previousPaw) => console.log(paw, previousPaw))
// Subscribe also supports an optional equality function
const unsub4 = useStore.subscribe(state => [state.paw, state.fur], console.log, { equalityFn: shallow })
// Subscribe and fire immediately
const unsub5 = useStore.subscribe(state => state.paw, console.log, { fireImmediately: true })
리덕스와 유사하다
- action 기반의 상태관리이며 객체 형태의 Store를 가진다.
- state 의 객체 property의 변화는 감지를 못하기 때문에 immer를 사용하는 것이 좋다.
timport produce from 'immer'
const useStore = create(set => ({
lush: { forest: { contains: { a: "bear" } } },
clearForest: () => set(produce(state => {
state.lush.forest.contains = null
}))
}))
const clearForest = useStore(state => state.clearForest)
clearForest();
- 리덕스의 형태도 지원한다.
const types = { increase: "INCREASE", decrease: "DECREASE" }
const reducer = (state, { type, by = 1 }) => {
switch (type) {
case types.increase: return { grumpiness: state.grumpiness + by }
case types.decrease: return { grumpiness: state.grumpiness - by }
}
}
const useStore = create(set => ({
grumpiness: 0,
dispatch: args => set(state => reducer(state, args)),
}))
const dispatch = useStore(state => state.dispatch)
dispatch({ type: types.increase, by: 2 })
import { redux } from 'zustand/middleware'
const useStore = create(redux(reducer, initialState))
리액트 없이도 사용이 가능
import create from 'zustand/vanilla'
const store = create(() => ({ ... }))
const { getState, setState, subscribe, destroy } = store
- atomic 한 형식이 아니라 외부 store를 이용하는 방식이라 그런지 리액트 없이 바닐라 자바스크립트로도 사용 가능하다.
기타
- react-tracked 를 이용해서 리렌더링을 더 줄일 수도 있다. (https://github.com/dai-shi/react-tracked)
Jotai VS Zustand
속성 | Jotai | Zustand |
---|---|---|
Github Stars⭐ | 6.8k | 12.7k |
state 모델 | primitive atoms 형태 | 단일 스토어 형태 |
형태 | useState 의 인터페이스를 기반으로한 여러 util (예 : useAtom) | useStore 로 거의 단일화되어있음 |
방식 | Bottom-up | Top-down |
Provider | 일반적으로는 필요함 | 필요 없음 |
바닐라 자바스크립트 | X (React 만 지원) | O |
Suspense 와 연계 | O | X ? (문서에서 언급이 안됨) |
devtool | O | O |
- Zustand 는 기본적으로 Centralized된 큰 store 안에 여러 state 들이 담긴다면, Jotai는 recoil 과 같이 primitive한 atoms 형태로 되어있다.
- Zustand 의 store 은 하나의 큰 object. (Top-down 방식)
- Jotai 의 state들은 원자 형태로 이루어짐. (Bottom-up 방식)
- Zustand는 store 중심으로 이루어져있으며, context 에 맞게 여러 store들을 선언하며,
useStore
훅을 이용해서 해당 store를 이용하는 방식이다.
- Jotai 와 Zustand 는 개발진이 같다고 하며, jotai 는 recoli, zustand 는 redux 의 형태와 유사하다.
- 그래서 두 공식문서 모두 Pmdnrs docs 에서 확인이 가능하다. (https://docs.pmnd.rs/)
- 여담으로, 자바스크립트 라이브러리 계의 유명인 Kent C. Dodds 는 jotai를 경험상 선호한다고 한다.
기타 자료
- https://github.com/pmndrs/jotai/issues/13 : How is jotai different from zustand?
Uploaded by Notion2Tistory v1.1.0