Jotai는 공식 홈페이지에서 나와 있는 것처럼 Recoil의 atom 모델에 영감을 받아 만들어 진 상태 관리 라이브러리이다.
Jotai는 상향식 접근법을 취하고 있다. 리덕스와 같이 하나의 큰 상태를 애플리케이션에 내려주는것이 아닌, 작은 단위의 상태를 위로 전파할 수 있는 구조를 가지고 있음을 의미한다. 이런 방식은 리액트 Context의 문제점인 불필요한 리렌더링이 일어나는 문제를 해결할 수 있다.메모이제이션 & 최적화 없이도 리렌더링이 발생하지 않는 구조이기 때문이다.
atom
Recoil과 마찬가지로 최소 단위의 상태를 의미한다.
const couterAtom = atom(0)
위와 같이 Atom을 만들면 아래와 같은 정보가 담긴다.
console.log(counterAtom)
//,...
//{
// init : 0,
// read : (get) => get(config),
// write : (get,set,update) =>
// set(config, typeof update === 'function' ? update(get(config)) : update)
//}
이 atom을 보기 전에 내부 구현을 잠깐 보자.
export function atom<Value, Update, Result extends void | Promise<void>>(
read: Value | Read<Value>,
write?: Write<Update, Result>
) {
const key = `atom${++keyCount}`
const config = {
toString: () => key,
} as WritableAtom<Value, Update, Result> & { init?: Value }
if (typeof read === 'function') {
config.read = read as Read<Value>
} else {
config.init = read
config.read = (get) => get(config)
config.write = (get, set, update) =>
set(config, typeof update === 'function' ? update(get(config)) : update)
}
if (write) {
config.write = write
}
return c
recoil의 atom과는 차이점이 존재한다. atom을 생성할때마다 key가 필요한 atom과 달리 Jotai는 atom을 생성하는 경우 별도의 key를 넘겨주지 않아도 된다.
Jotai의 atom은 config란 객체를 반환하는데 이 config에는 초기값을 의미하는 init , 값을 가져오는 read, 설정하는 writed만 존재한다. 즉 Jotai의 atom에는 따로상태를 저장하지 않고 있는데 이 상태는 어디에 저장하고 있을까
useAtomValue
해법은 이 훅에 있다.
export function useAtomValue<Value>(
atom: Atom<Value>,
scope?: Scope
): Awaited<Value> {
const ScopeContext = getScopeContext(scope)
const scopeContainer = useContext(ScopeContext)
const { s: store, v: versionFromProvider } = scopeContainer
const getAtomValue = (version?: VersionObject) => {
// This call to READ_ATOM is the place where derived atoms will actually be
// recomputed if needed.
const atomState = store[READ_ATOM](atom, version)
if (__DEV__ && !atomState.y) {
throw new Error('should not be invalidated')
}
if ('e' in atomState) {
throw atomState.e // read error
}
if ('p' in atomState) {
throw atomState.p // read promise
}
if ('v' in atomState) {
return atomState.v as Awaited<Value>
}
throw new Error('no atom value')
}
// Pull the atoms's state from the store into React state.
const [[version, valueFromReducer, atomFromReducer], rerenderIfChanged] =
useReducer<
Reducer<
readonly [VersionObject | undefined, Awaited<Value>, Atom<Value>],
VersionObject | undefined
>,
VersionObject | undefined
>(
(prev, nextVersion) => {
const nextValue = getAtomValue(nextVersion)
if (Object.is(prev[1], nextValue) && prev[2] === atom) {
return prev // bail out
}
return [nextVersion, nextValue, atom]
},
versionFromProvider,
(initialVersion) => {
const initialValue = getAtomValue(initialVersion)
return [initialVersion, initialValue, atom]
}
)
let value = valueFromReducer
if (atomFromReducer !== atom) {
rerenderIfChanged(version)
value = getAtomValue(version)
}
useEffect(() => {
const { v: versionFromProvider } = scopeContainer
if (versionFromProvider) {
store[COMMIT_ATOM](atom, versionFromProvider)
}
// Call `rerenderIfChanged` whenever this atom is invalidated. Note
// that derived atoms may not be recomputed yet.
const unsubscribe = store[SUBSCRIBE_ATOM](
atom,
rerenderIfChanged,
versionFromProvider
)
rerenderIfChanged(versionFromProvider)
return unsubscribe
}, [store, atom, scopeContainer])
useEffect(() => {
store[COMMIT_ATOM](atom, version)
})
useDebugValue(value)
return value
}
useReducer를 보자 useReducer에서 반환하는 상태값은 3가지이다. [version,valueFromReducer,atomFromReducer ] 첫번째는 store의 버전 , 두번째는 atom에서 get을 수행했을 때 반환되는 값 , 세번째는 atom 그그 자체르 ㄹ의미한다.
Recoil과는 다르게 컴포넌트 루트 레벨에서 Context가 존재하지 않아도 되는데 Context가 없다면 Provider 없이 기본 스토어를 루트에 생성하고 이를 활용하여 값을 저장할 수 있다.
물론 Joati에서 export하는 Provider를 사용하여 각 Provider별로 다른 atom값을 관리하는 것 또한 가능하다.
useAtom
useAtom은 useState와 동일한 형태의 배열을 반환한다.
export function useAtom<Value, Update, Result extends void | Promise<void>>(
atom: Atom<Value> | WritableAtom<Value, Update, Result>,
scope?: Scope
) {
if ('scope' in atom) {
console.warn(
'atom.scope is deprecated. Please do useAtom(atom, scope) instead.'
)
scope = (atom as { scope: Scope }).scope
}
return [
useAtomValue(atom, scope),
// We do wrong type assertion here, which results in throwing an error.
useSetAtom(atom as WritableAtom<Value, Update, Result>, scope),
]
}
setAtom으로 이름지어져있는 write함수를 보면 write함수는 스토어에서 해당 atom을 찾아서 직접 값을 업데이트한다.
지난 글에서 해본 간단한 예시를 해보자.
import { atom, useAtom, useAtomValue } from "jotai";
import React from "react";
const counterState = atom(0);
function Counter() {
const [, setCount] = useAtom(counterState);
function handleButtonClick() {
setCount((count) => count + 1);
}
return (
<>
<button onClick={handleButtonClick}>+</button>
</>
);
}
const isBiggerThan10 = atom((get) => get(counterState) > 10);
function Count() {
const count = useAtomValue(counterState);
const biggerThan10 = useAtomValue(isBiggerThan10);
return (
<>
<h3>{count}</h3>
<p>count is bigger than 10 : {JSON.stringify(biggerThan10)}</p>
</>
);
}
const MyComponent = () => {
return (
<>
<Counter></Counter>
<Count></Count>
</>
);
};
/* STYLE */
export default MyComponent;
라이브러리의 태생 자체가 Recoil에서 영감을 받은만큼 Recoil과 유사한점이 많다. 동시에 Recoil이 가지고 있는 몇가지 한계점 또한 노력했다. atom 개념을 도입하면서도 API가 간결하며 각 상태값 별로 키가 필요가 없는 등 유리한 점들이 있다.
Recoil에서 키를 활용해 atom을 관리하는데 Jotai에서는 이런 부분들을 추상화하여 다루고 있다. 객체 의 잠조를 통해 값을 관리하기 때문에 보다 간결된 형태로 정보를 저장하는 것이 가능하다.
Zustand
Zustand는 리덕스에 영감을 받아 만들어졌따. atom이라는 최소 단위의 상태를 관리하는 것이 아니라 하나의 스토어를 중앙 집중형으로 활용하여 해당 스토어 내부에서 상태를 관리하고 있다.
Zustand에서 스토어를 만드는 코드를 보자.
const createStoreImpl: CreateStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>
type Listener = (state: TState, prevState: TState) => void
let state: TState
const listeners: Set<Listener> = new Set()
const setState: SetStateInternal<TState> = (partial, replace) => {
// TODO: Remove type assertion once https://github.com/microsoft/TypeScript/issues/37663 is resolved
// https://github.com/microsoft/TypeScript/issues/37663#issuecomment-759728342
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
if (nextState !== state) {
const previousState = state
state =
replace ?? typeof nextState !== 'object'
? (nextState as TState)
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
const getState: () => TState = () => state
const subscribe: (listener: Listener) => () => void = (listener) => {
listeners.add(listener)
// Unsubscribe
return () => listeners.delete(listener)
}
const destroy: () => void = () => listeners.clear()
const api = { setState, getState, subscribe, destroy }
state = (createState as PopArgument<typeof createState>)(
setState,
getState,
api
)
return api as any
}
스토어 구조는 앞서 만들어본 스토어와 유사하게 state의 값을 useState외부에서 관리한다. 조금 특이한 점은 partial과 replace로 나누어져 있는 부분인데 partial은 state의 일부분을 변경하고 싶을때 , replace는 state를 완전히 새로운 값으로 변경하고 싶을 때 사용할 수 있다.
이 store코드가 있는 파일을 유심히 보면 재밌는 사실 몇가지가 있따. 해당 파일에서 export하는 유일한 함수는 createStore밖에 없고 그 외에는 모두 이 createStore를 이용하는데 필요한 타입만 존재한다. 또한 그 어떤것도 import하고 있지 않으며 해당 store는 리액트를 비롯한 그 어떤 프레임워크와는 별개로 구성되어 있다는 것을 알 수 있다.
실제로 순수 자바스크립트 환경에서도 사용 가능하다.
type CounterStore = {
counter : number
increase : (num : number) => void
}
const store = createStore<CounterStore>((set) => ({
count : 0,
increase : (num : number) => set((state) => ({ count : state.count + num})),
}))
store.subscribe((state,prev) => {
if(state.count !== prev.count){
console.log('count has been changed',state.count)
}
})
store.setState((state) => ({count : state.count + 1})
store.getState().increase(10)
또한 createStore로 스토어를 만들때 set이라는 인수를 활용해서 만들 수 있는데 이 set을 활용하면 현재 스토어의 값을 재정의할수도 있고 두번째 인수로 get을 추가하여 현재 스토어의 값을 받아오는 것 또한 가능하다.
Zustand를 리액트에서 사용하기 위해서는 어디선가 이 store를 읽고 리렌더링을 해야한다. 이 함수들은 /src/react.ts에서 관리되고 있다.
useStore
https://github.com/pmndrs/zustand/blob/eea3944499883eae1cf168770ed85c05afc2aae9/src/react.ts#L33-L47
export function useStore<TState, StateSlice>(
api: WithReact<StoreApi<TState>>,
selector: (state: TState) => StateSlice = api.getState as any,
equalityFn?: (a: StateSlice, b: StateSlice) => boolean
) {
const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getServerState || api.getState,
selector,
equalityFn
)
useDebugValue(slice)
return slice
}
useSyncExternalStoreWithSelector를 활용하여 앞선 useStore의 subscribe,getState를 넘겨준 후 스토어에서 선택을 원하는 함수인 selector을 넘겨주고 끝나게 된다.
여기서 useSyncExternalStore는 리액트 18에서 새로 만들어진 훅으로 리액트 외부에서 관리되는 상태값을 리액트에서 사용할 수 있게 도와준다.
create
리액트에서 사용할 수 있는 스토어를 만들어주는 변수이다. 바닐라의 createStore를 기반으로 만들어 졌기 때문에 거의 유사하며 useStore를 활용하여 해당 스토어가 즉시 리액트 컴포넌트에서 사용할 수 있도록 만들어 졌다는 특징이 있다.
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
const api =
typeof createState === 'function' ? createStore(createState) : createState
const useBoundStore: any = (selector?: any, equalityFn?: any) =>
useStore(api, selector, equalityFn)
Object.assign(useBoundStore, api)
return useBoundStore
}
const create = (<T>(createState: StateCreator<T, [], []> | undefined) =>
createState ? createImpl(createState) : createImpl) as Create
export default create
위와같은 간결한 구조 덕에 리액트 환경에서도 스토어를 생성하고 사용하기가 용이하다는 장점이 있다.
이번글의 jotai와 저번글의 recoil의 예제를 마찬가지로 만들어보자.
import React from "react";
import { create } from "zustand";
interface CounterState {
count: number;
inc: () => void;
dec: () => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
dec: () => set((state) => ({ count: state.count - 1 })),
}));
function Counter() {
const { count, inc, dec } = useCounterStore();
return (
<div className="counter">
<span>{count}</span>
<button onClick={inc}>up</button>
<button onClick={dec}>down</button>
</div>
);
}
const MyComponent = () => {
return <Counter />;
};
/* STYLE */
export default MyComponent;
리덕스와 비슷하지만 이보다 더 간결한 형식으로 상태관리가 비슷하다.
createStore을 통하면 리액트 컴포넌트 외부에 store를 만드는 것도 가능하다.
import React from "react";
import { createStore, useStore } from "zustand";
interface CounterState {
count: number;
inc: () => void;
dec: () => void;
}
const counterStore = createStore<CounterState>((set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
dec: () => set((state) => ({ count: state.count - 1 })),
}));
function Counter() {
const { count, inc, dec } = useStore(counterStore);
return (
<div className="counter">
<span>{count}</span>
<button onClick={inc}>up</button>
<button onClick={dec}>down</button>
</div>
);
}
const MyComponent = () => {
return <Counter />;
};
/* STYLE */
export default MyComponent;
이러면 리액트와 상관없는 바닐라 스토어를 만들 수 있게 된다.
Zustand는 방금 본 예제처럼 특별히 많은 코드 작성 없이 스토어를 만들고 사용할 수 있다는 매우 큰 장점이 있다. 이는 리덕스와 비교했을때 굉장히 좋은 장점이다. 또한 Zustand 라이브러리의 크기 자체도 매우 작다.
이렇게 API가 복잡하지 않고 간단하다는 장점이 있다. 리덕스처럼 미들웨어또한 지원하며 스토어 데이터를 영구보존해주는 persist, 복잡한 객체를 관리하기 쉽게 해주는 immer , 리덕스와 함께 사용할 수 있는 리덕스 미들웨어 등등 여러가지 미들웨어를 제공해준다.
결국 각 라이브러리마다 상태를 관리하는 방식이 조금씩은 다르지만 리액트에서 리렌더링을 일으키는 방법 자체는 유사하단 점을 알 수 있었다. 각 라이브러리의 특징을 잘 파악하고 현재 애플리케이션과의 상황에 맞게 선택하는 역량이 필요하다!!
항상 전역상태 라이브러리를 사용만 했었지 어떻게 동작하는지에 대해서는 아는게 별로 없었는데 많이 알고가게 되었던 것 같다..
끄읏-!
'FrontEnd > Deep Dive' 카테고리의 다른 글
[React] Deep Dive 모던 리액트(21) 테스트 (0) | 2024.01.17 |
---|---|
[React] Deep Dive 모던 리액트(19) 리액트 개발 도구 (0) | 2024.01.08 |
[React] Deep Dive 모던 리액트(17) Recoil 살펴보기 (0) | 2024.01.07 |
[React] Deep Dive 모던 리액트(16) 훅을 통한 전역상태관리 구현 (0) | 2024.01.03 |
[React] Deep Dive 모던 리액트(15) 리액트와 상태관리 라이브러리 역사 (0) | 2024.01.02 |