Recoil과 Jotai는 atomic한 상태관리를 위해 atom이라는 이름의 상태 단위를 사용합니다.
Recoil과 Jotai는 각각 어떤 방식으로 이 atom이라는 단위를 사용할까요?
Recoil의 Atom은 options : { key :아톰 키 , default: 상태 초기값 } 객체를 전달하여 상태를 선언합니다.
const fontSizeState = atom({
key: "fontSizeState",
default: 14,
});
이때, atom은 다른 아톰 상태값인 RecoilValue를 받아 파생 상태를 만들 수 있고, 이 경우 아톰의 상태는 체이닝됩니다.
다음의 atom함수 구현체를 보면, atom이 RecoilValue를 받는지, 사용자 정의 초기값을 받는지에 대한 분기처리가 존재함을 알 수 있습니다.
function atom<T>(options: AtomOptions<T>): RecoilState<T> {
const {
...restOptions
} = options;
const optionsDefault: RecoilValue<T> | Promise<T> | Loadable<T> | WrappedValue<T> | T =
'default' in options
?
options.default
: new Promise(() => {});
// 아톰이 RecoilValue를 초기값으로 받게된다면 atomWithFallback
if (isRecoilValue(optionsDefault)
) {
return atomWithFallback<T>({
...restOptions,
default: optionsDefault,
} else {
return baseAtom<T>({...restOptions, default: optionsDefault});
}
}
atom이 RecoilValue를 받게되면, atomWithFallback을 호출하고, 안쪽에서 atom함수를 재귀적으로 호출하며, 모든 아톰 상태를 체이닝하게 됩니다.
atom이 RecoilValue(atom상태값)을 받지 않는다면 baseAtom으로 atom을 초기화합니다.
Jotai의 atom은 Recoil과 달리 별도 key값을 받지 않습니다.
특이한 점은 atom은 read와 write라는 값을 입력받습니다.
- read ( 값 또는 함수 ) : atom의 초기값 혹은 파생된 값을 반환하는 Getter
- write ( 함수 ) : Setter
위와같은 유연한 설정으로 atom은
- Read-only atom
- Write-only atom
- Read-Write atom
세 가지 atom 사용 방식을 구현할 수 있습니다.
atom 구현체입니다.
export function atom<Value, Args extends unknown[], Result>(read?: Value | Read<Value, SetAtom<Args, Result>>, write?: Write<Args, Result>) {
const key = `atom${++keyCount}`;
const config = {
toString() {
return import.meta.env?.MODE !== "production" && this.debugLabel ? key + ":" + this.debugLabel : key;
},
} as WritableAtom<Value, Args, Result> & { init?: Value | undefined };
if (typeof read === "function") {
config.read = read as Read<Value, SetAtom<Args, Result>>;
} else {
config.init = read;
config.read = defaultRead;
config.write = defaultWrite as unknown as Write<Args, Result>;
}
if (write) {
config.write = write;
}
return config;
}
코드를 보면 아톰 key값을 내부적으로 keyCount를 증가시키며 관리해주고 있네요,
또한 Jotai의 atom은 내부적으로 config라는 객체를 선언하며 이 객체에 초기화된 atom값, read(getter), write(setter)를 설정하고 있습니다.
atom에서 반환된 config객체는 추후 useAtom의 인자로 들어가며, [state,setState] 패턴으로 구조분해됩니다.
export function useAtom<Value, Args extends unknown[], Result>(
atom: Atom<Value> | WritableAtom<Value, Args, Result>, // config 객체 (atom)
options?: Options
) {
return [useAtomValue(atom, options), useSetAtom(atom as WritableAtom<Value, Args, Result>, options)];
}
컴포넌트는 useAtomValue로 atom상태값을 구독합니다.
단, jotai는 useSyncExternalStore를 통한 상태 업데이트 방식을 사용하지 않습니다. useReducer 기반 상태 관리 방식을 취하므로써 리액트 동시성모드에서 일시적 tearing은 감수하되, time-slicing (렌더링 우선순위조정) : 성능최적화를 우선하는 방식의 트레이드오프 전략을 취합니다.
https://blog.axlight.com/posts/why-use-sync-external-store-is-not-used-in-jotai/
export function useAtomValue<Value>(atom: Atom<Value>, options?: Options) {
const store = useStore(options)
const [[valueFromReducer, storeFromReducer, atomFromReducer], rerender] =
useReducer<
ReducerWithoutAction<readonly [Value, Store, typeof atom]>,
undefined
>(
(prev) => {
const nextValue = store.get(atom)
if (
Object.is(prev[0], nextValue) &&
prev[1] === store &&
prev[2] === atom
) {
return prev
}
return [nextValue, store, atom]
},
undefined,
() => [store.get(atom), store, atom],
)
let value = valueFromReducer
if (storeFromReducer !== store || atomFromReducer !== atom) {
rerender()
value = store.get(atom)
}
Recoil의 경우 컴포넌트는 useRecoilValue를 통해 atom상태를 구독하고 값이 변경되 경우 업데이트하는 방식을 취합니다.
특이한 점은, 리코일은 버전에 따른 업데이트 방식의 분기처리가 존재합니다.
React 18+ 환경
- useSyncExternalStore 훅 활용
- RecoilRoot Provider의 store 객체와 동기화
- 추가 React 렌더러(react-three-fiber 등) 미지원 시 TRANSITION_SUPPORT로 폴백
React 18 이전 환경
- useState + useEffect + forceUpdate 조합 사용
- 배치 업데이트 처리를 위한 추가 로직 포함
TRANSITION_SUPPORT 모드
- Concurrent Mode와 Transition API 지원
- 성능 최적화된 상태 업데이트 처리
useRecoilValue는 내부적으로 useRecoilValueLoadable을 호출합니다.
function useRecoilValueLoadable<T>(recoilValue: RecoilValue<T>): Loadable<T> {
if (__DEV__) {
validateRecoilValue(recoilValue, "useRecoilValueLoadable");
}
if (gkx("recoil_memory_managament_2020")) {
useRetain(recoilValue);
}
return {
TRANSITION_SUPPORT: useRecoilValueLoadable_TRANSITION_SUPPORT,
SYNC_EXTERNAL_STORE: currentRendererSupportsUseSyncExternalStore() ? useRecoilValueLoadable_SYNC_EXTERNAL_STORE : useRecoilValueLoadable_TRANSITION_SUPPORT,
LEGACY: useRecoilValueLoadable_LEGACY,
}[reactMode().mode](recoilValue);
}
위와같이 리액트 버전에 따라 서로 다른 버전의 useRecoilValueLoadable가 호출되며,
리액트 18+ 기준으로 useSyncExternalStore를 통해 전역상태를 맞추게 됩니다.
리코일의 경우 RecoilRoot Provider가 존재하므로, Provider에 주입되는 store객체와 sync를 맞추는 방식으로 동작합니다.