Zustand vs Jotai

두 라이브러리 모두 상태를 React 밖의 store에 두고, 구독한 컴포넌트만 리렌더한다. 차이는 store 값을 React 렌더에 연결하는 방법과, 그 방법이 정하는 업데이트 우선순위 · 일관성 보장 · Suspense 지원이다. 각 항목의 근거는 Zustand · Jotai 글에 있다.

React에 값을 연결하는 방법

둘 다 상태는 React 밖에 있다. Zustand의 store도, Jotai의 store(createStore()가 만드는 객체, Provider 없이 쓰면 모듈 수준 기본 store)도 React state가 아니다. Jotai의 Provider가 Context에 넣는 것은 atom 값이 아니라 store 참조 하나다. Context는 store를 주입하는 용도로만 쓰이고 값 전파에는 쓰이지 않아서, 값이 바뀌어도 Context consumer 전체가 리렌더되지 않는다. 구독한 컴포넌트만 리렌더되는 것은 두 라이브러리가 같다.

차이는 외부 store의 값을 React 렌더에 연결하는 방법이다.

Zustand — useSyncExternalStore

위 useStore 코드처럼 useSyncExternalStore(subscribe, getSnapshot)를 쓴다.

  • React가 렌더 중에 getSnapshot을 동기로 호출해 값을 읽는다. getSnapshot은 같은 상태에서 같은 참조를 돌려줘야 한다(selector가 매번 새 객체를 만들면 무한 리렌더).
  • 렌더가 시작된 뒤 커밋 전에 store 값이 바뀌면, React는 그 렌더를 버리고 동기 재렌더로 떨어뜨린다. 같은 커밋 안의 두 컴포넌트가 store의 다른 시점 값을 보는 상태(tearing)를 이 방식으로 막는다.
  • getSnapshot은 동기 값을 반환해야 하므로, 렌더 중에 Promise를 던져 Suspense로 넘기는 설계와는 맞지 않는다.

Jotai v2 — useReducer + 직접 구독

Jotai는 useSyncExternalStore를 쓰지 않는다. useAtomValue의 요지만 옮기면 다음과 같다.

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가 바뀌면 이 컴포넌트만 리렌더
    rerender(); // 구독 사이에 바뀐 값을 한 번 맞춘다
    return unsub;
  }, [store, atom]);

  if (isPromiseLike(value)) use(value); // 비동기 atom은 Promise를 throw해 Suspense로 넘긴다
  return value;
}
  • 값은 useReducer가 들고 있고, useEffect에서 store.sub로 구독해 바뀌면 rerender를 부른다. “외부 store + 직접 구독 + 로컬 리렌더 트리거” 구조다.
  • 이렇게 한 이유는 둘이다. 첫째, 비동기 atom. atom 값이 Promise면 렌더 중에 throw해 가장 가까운 Suspense가 fallback을 그린다. useSyncExternalStore의 getSnapshot은 동기 값을 전제로 하므로 이 설계를 담을 수 없다. 둘째, transition. useSyncExternalStore는 store 변경을 항상 동기 렌더로 처리해서 startTransition으로 감싼 store 업데이트가 transition으로 동작하지 않는다. Jotai는 atom 업데이트를 transition으로 다룰 수 있게 하려고 React state(useReducer)를 거치는 길을 택했다.
  • 값이 useReducer state에 있으므로 렌더 중에 외부 값을 직접 읽지 않는다. store 변경은 rerender를 통해 React 업데이트로 들어가고, React가 우선순위에 따라 진행 중인 렌더를 버리거나 뒤에 처리한다. 그래서 커밋 결과에 두 시점 값이 섞이는 일은 useState에 값을 복사하는 방식과 같은 수준으로 막힌다. useSyncExternalStore처럼 커밋 직전 재검사와 동기 재렌더로 보장하는 것은 아니다. 구독이 effect(커밋 뒤)에 걸리는 틈은 effect 안에서 rerender()를 한 번 더 호출해 메운다.

tearing

concurrent rendering에서 React는 렌더를 중단했다가 이어서 할 수 있다. 그 사이에 외부 store 값이 바뀌면, 먼저 렌더된 컴포넌트는 이전 값을, 나중에 렌더된 컴포넌트는 새 값을 읽어 한 번의 커밋에 두 시점의 값이 같이 들어갈 수 있다. React state는 React가 업데이트 적용 시점을 통제하므로 이 문제가 없고, 외부 store는 React 밖에서 바뀌기 때문에 생긴다.

실제로 커밋까지 가는 조건은 좁다. 렌더 중에 외부 값을 React state를 거치지 않고 직접 읽고(return store.get()), 그 사이 들어온 store 변경이 진행 중인 렌더와 같은 우선순위(transition) 로 예약돼 React가 렌더를 재시작하지 않고 이어서 커밋할 때다. 변경이 더 높은 우선순위(이벤트 핸들러 · setTimeout의 기본 우선순위)로 들어오면 React는 진행 중인 렌더를 버리고 그 업데이트를 먼저 처리해 커밋 결과는 일관된다. 값을 useState · useReducer에 복사해 두는 방식은 렌더 중 읽는 값이 React state라서 애초에 섞이지 않는다.

useSyncExternalStore는 커밋 직전에 getSnapshot을 다시 호출해 렌더 시작 값과 다르면 그 렌더를 버리고 동기 렌더로 다시 그린다. 직접 읽는 방식에서도 tearing을 막아 주지만, 그 대가로 store 변경이 걸린 렌더는 transition의 이점(끊어서 급한 입력 먼저 처리)을 잃는다. 긴 목록을 transition으로 그리는 중에 store가 바뀌면 동기 재렌더 동안 타이핑이 멈춘다.

비교표

  Zustand Jotai
상태 단위 store 하나(객체), selector로 조각을 읽음 atom 여러 개, 파생 atom으로 합성
store 위치 React 밖. create()가 만든 클로저 React 밖. createStore() 또는 기본 store. Provider는 store 참조만 주입
React 연결 useSyncExternalStore(subscribe, getSnapshot) useReducer에 값 복사 + useEffect에서 store.sub
렌더 중 읽는 값 getSnapshot()이 돌려준 store의 현재 값 reducer state(복사본)
store 변경 → 업데이트 우선순위 항상 동기. startTransition 안에서 바꿔도 transition이 되지 않음 변경이 일어난 자리를 따름. 핸들러면 높음, setTimeout이면 기본, startTransition 안이면 transition
일관성(tearing) 보장 커밋 직전 getSnapshot 재확인 → 다르면 버리고 동기 재렌더 별도 검사 없음. 렌더 중 읽는 값이 React state라 같은 렌더 안에서는 한 값
batching 같은 동기 틱 안의 연속 변경만 묶임. transition 큐에는 들어가지 않음 React 규칙 그대로. transition이면 진행 중인 렌더 뒤로 밀리며 묶임
긴 렌더 중 입력 반응성 동기 렌더 동안 입력이 밀림. 비싼 자식은 useDeferredValue로 따로 미뤄야 함 변경을 startTransition으로 감싸면 입력 먼저, 큰 트리는 뒤에
개별 변경이 화면에 닿는 시간 짧음(미루지 않음) transition이면 묶여서 늦게 보일 수 있음
비동기 · Suspense store 값은 동기. 비동기는 밖에서 처리 atom 값이 Promise면 throw → Suspense
React 밖에서 읽기 · 쓰기 store.getState() · setState() store.get(atom) · store.set(atom, v)
리렌더 범위 selector가 돌려준 값이 Object.is로 같으면 건너뜀 구독한 atom이 바뀐 컴포넌트만
파생 값 selector 함수(매번 계산, 메모는 직접) 파생 atom(의존 atom이 바뀔 때만 재계산, 캐시됨)
Context 사용 없음(선택적으로 store를 Context에 넣을 수 있음) store 주입용으로만. 값 전파에는 안 씀

고르는 기준

상황 고를 쪽 이유
동기 전역 상태 몇 개(모달 열림, 선택 id, 토큰) 둘 다 무방 차이가 번들 · 팀 익숙함뿐. 메커니즘으로 고르면 과장
파생 상태가 많고 의존 관계가 그래프 모양 Jotai 파생 atom이 의존 atom 변경 때만 재계산하고 캐시
비동기 값을 Suspense 경계로 다루고 싶음 Jotai atom이 Promise를 throw. uSES는 동기 값만
큰 트리가 store를 구독하고 store가 자주 바뀌며(드래그 · 실시간) 동시에 입력이 있음 Jotai + startTransition, 또는 Zustand + useDeferredValue uSES의 동기 렌더가 입력을 막을 수 있음. Zustand에서는 비싼 자식을 컴포넌트 쪽에서 미룸
한 store를 React 밖(이벤트 핸들러 · 테스트 · 미들웨어)에서 많이 다룸 Zustand getState · setState · subscribe API와 미들웨어 생태계(persist · devtools)
변경이 화면에 즉시 닿아야 하고 트리가 작음 Zustand 미루지 않음. 일관성 검사가 공짜

“Zustand가 느릴 수 있나”에 대한 답: 큰 구독 트리 · 잦은 변경 · 동시 입력 세 조건이 겹칠 때 uSES의 동기 렌더가 입력을 막을 수 있다. 그 경우 useDeferredValue로 비싼 부분만 미루거나 selector로 구독 범위를 좁힌다. 세 조건이 아니면 차이가 없다. 조건 없이 “느리다”고 하면 측정 없는 성능 주장이다.

공통 주의

  • 둘 다 “저장 목적의 전역 상태”로 쓰지 않는다. 서버 데이터는 서버 상태 라이브러리에, 라우팅 상태는 URL에, 폼 입력은 지역 상태에 둔다(FF Discussions #5). 라이브러리 선택 전에 그 상태의 성격과 수명(새로고침 · 뒤로가기 · 다른 대상 진입 · 완료 뒤)을 먼저 정한다.
  • 전역 store에 둔 값은 테스트 · SSR · Storybook에서 초기화 지점이 필요하다. Zustand는 store를 테스트마다 새로 만들거나 setState로 리셋, Jotai는 Provider에 새 store를 넘긴다.