Jotai
Jotai
atom 단위로 상태를 정의하고, atom을 구독한 컴포넌트만 리렌더되는 상태 관리 라이브러리. 이 글은 사용법이 아니라 Jotai가 값을 React에 어떻게 연결하는지를 다룬다.
store는 React 밖에 있다
-
createStore()가 만드는 store 객체가 atom 값을 들고 있다. React state가 아니다. -
Provider없이 쓰면 모듈 수준의 기본 store 하나를 쓴다.Provider는 Context에 store 참조 하나를 넣는다. atom 값이 Context를 타고 전파되지 않으므로, 값이 바뀌어도 Context consumer 전체가 리렌더되는 일은 없다. - Context의 역할은 “어느 store를 쓸지” 주입하는 것뿐이다. FF Discussions #5의 “Context는 상태 관리 도구가 아니라 의존성 주입 도구”가 Jotai 구조에 그대로 해당한다.
useAtomValue가 값을 React에 연결하는 방법
요지만 옮기면 다음과 같다.
export function useAtomValue(atom, options) {
const store = useStore(options); // Context에서 store 참조만 꺼낸다
const [[value, storeFromReducer, atomFromReducer], rerender] = useReducer(
(prev) => {
const next = store.get(atom);
if (Object.is(prev[0], next) && prev[1] === store && prev[2] === atom) return prev;
return [next, store, atom];
},
undefined,
() => [store.get(atom), store, atom]
);
useEffect(() => {
const unsub = store.sub(atom, () => rerender()); // store가 바뀌면 이 컴포넌트에 React 업데이트를 건다
rerender(); // 렌더와 구독 사이에 바뀐 값을 한 번 맞춘다
return unsub;
}, [store, atom]);
if (isPromiseLike(value)) use(value); // 비동기 atom은 Promise를 throw해 Suspense로 넘긴다
return value;
}
구조는 외부 store + useEffect 구독 + useReducer에 값 복사다. 세 가지가 따라온다.
-
렌더 중에 읽는 값은 React state다.
store.get(atom)을 렌더에서 직접 반환하지 않고 reducer state에 복사해 둔 값을 반환한다. 그래서 렌더 도중 store가 바뀌어도 그 렌더 안의 컴포넌트들은 같은 state 값을 읽는다. -
store 변경은 React 업데이트가 된다.
store.sub콜백이rerender(dispatch)를 부르므로, 변경은 React의 업데이트 큐에 들어가고 React가 우선순위에 따라 처리한다. Jotai가 직접 커밋 시점을 정하지 않는다. -
업데이트의 우선순위는 변경이 일어난 자리를 따른다. 클릭 핸들러 안에서
set하면 높은 우선순위,setTimeout에서 하면 기본,startTransition안에서 하면 transition 우선순위다. Jotai는 여기에 아무것도 더하지 않는다.
왜 useSyncExternalStore를 쓰지 않는가
React는 외부 store를 연결하는 공식 API로 useSyncExternalStore(이하 uSES)를 제공한다. Jotai가 이것을 쓰지 않은 이유는 둘이다.
-
비동기 atom. atom 값이 Promise면 렌더 중에 throw해 가장 가까운 Suspense가 fallback을 그린다. uSES의
getSnapshot은 동기 값을 돌려줘야 하고 렌더 중 throw를 전제로 하지 않아서 이 설계를 담을 수 없다. -
transition. uSES는 store 변경으로 생긴 업데이트를 항상 동기 우선순위로 올린다.
startTransition안에서 store를 바꿔도 transition이 되지 않는다. Jotai는 atom 업데이트를 transition으로 다룰 수 있게 하려고 React state를 거치는 길을 택했다.
대가는 uSES가 주는 보장을 받지 않는다는 것이다. uSES는 커밋 직전에 getSnapshot을 다시 읽어 렌더 시작 값과 다르면 그 렌더를 버리고 동기로 다시 그린다. Jotai에는 이 검사가 없다. 다만 1번(렌더 중 읽는 값이 React state)이기 때문에 커밋 결과에 두 시점 값이 섞이는 일은 useState에 값을 복사하는 방식과 같은 수준으로 막힌다. 구독이 effect(커밋 뒤)에 걸리는 틈은 effect 안에서 rerender()를 한 번 더 호출해 메운다.
업데이트 우선순위와 batching
React 18부터 모든 state 업데이트에는 우선순위가 붙고, 발생 위치가 우선순위를 정한다.
| 발생 위치 | 우선순위 | React가 하는 일 |
|---|---|---|
| 클릭 · 키 입력 핸들러 안 | 동기(가장 높음) | 바로 렌더하고 커밋. 끊지 않는다 |
| 스크롤 · 마우스 이동 핸들러 안 | 높음 | 거의 바로 |
setTimeout · fetch 응답 · 구독 콜백 |
기본 | 곧 처리. 진행 중인 transition 렌더가 있으면 중단시키고 먼저 |
startTransition(() => …) 안 |
transition(가장 낮음) | 이전 화면을 유지한 채 뒤에서 렌더. 끊길 수 있고 같은 우선순위끼리 묶인다 |
Jotai의 store.sub 콜백이 부르는 rerender는 평범한 React 업데이트라서 이 표를 그대로 따른다. atom을 startTransition 안에서 바꾸면 transition 업데이트가 되고, 핸들러에서 바꾸면 동기 업데이트가 된다. Jotai는 우선순위를 정하지 않는다.
batching. React는 같은 우선순위의 업데이트를 하나의 큐에 쌓고, 처리할 차례가 되면 큐를 끝까지 적용한 최종 state로 한 번 렌더한다. 중간 state는 렌더되지 않는다. 묶이는 조건은 둘이다.
- 같은 우선순위일 것
- 앞의 업데이트가 아직 처리되지 않았을 것(진행 중인 렌더 뒤에 밀려 있거나 같은 틱 안)
transition 업데이트는 진행 중인 렌더 뒤로 밀리므로 2번이 자주 성립해 묶이기 쉽다. 동기 업데이트는 같은 틱 안에서만 묶인다. 이 규칙은 Jotai의 것이 아니라 React의 것이고, useState에 값을 복사하는 방식과 Jotai는 같은 결과를 낸다.
uSES와의 차이. useSyncExternalStore 구독으로 들어온 store 변경은 발생 위치와 무관하게 항상 동기 우선순위로 예약된다. transition 큐에 들어가지 않으므로 “렌더 뒤로 밀리며 묶이는 일”이 없고, 진행 중인 transition 렌더를 중단시킨다. uSES로 연결한 store에서는 업데이트를 transition으로 다룰 수 없다.
tearing이 커밋되는 조건. 렌더 중에 외부 값을 React state를 거치지 않고 직접 읽고(return store.get()), 그 사이 들어온 변경이 진행 중인 렌더와 같은 우선순위(transition)라 React가 렌더를 재시작하지 않고 이어서 커밋할 때다. 변경이 더 높은 우선순위면 React가 진행 중인 렌더를 버리고 변경을 먼저 처리해 커밋 결과는 일관된다. Jotai는 렌더 중 읽는 값이 reducer state라 이 조건의 첫 번째를 만족하지 않는다.
언제 Jotai를 고르는가
- 고르는 이유가 되는 것: atom 합성(파생 atom), 비동기 atom을 Suspense로 다루는 것, atom 업데이트를 transition으로 처리해 큰 트리를 그리는 동안 입력 반응성을 유지하는 것.
- 고르는 이유가 안 되는 것: “구독한 컴포넌트만 리렌더된다”. 외부 store를 쓰는 다른 라이브러리도 selector로 같은 범위를 만든다.
- 동기 전역 상태 몇 개뿐인 화면이면 다른 전역 상태 라이브러리와의 차이는 번들 크기와 팀 익숙함이다. 이 경우 “왜 Jotai인가”의 답은 메커니즘이 아니라 팀 조건이어야 한다.
함께 볼 것
- useSyncExternalStore — React가 제공하는 외부 store 연결 API와 tearing 방지
- useTransition — transition 우선순위
- Zustand vs Jotai — 비교표