← 글 목록

notes

useSyncExternalStore

useSyncExternalStore의 외부 스토어 구독·스냅샷 처리와 React 연동 메모.

  • React 밖에 있는 값을 구독과 스냅샷으로 읽는 훅이다
  • useEffect 로 흉내 내면 SSR 과 동시 렌더링에서 값이 어긋난다
  • getSnapshot 은 같은 상태면 꼭 같은 값을 돌려줘야 한다

왜 필요한가

window.innerWidth, matchMedia, localStorage, online 이벤트 같은 건 React 가 모르는 값이다. 예전에는 useState 에 담고 useEffect 에서 이벤트를 달아 갱신했다.

const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener('resize', onResize);
  return () => window.removeEventListener('resize', onResize);
}, []);

이렇게 하면 두 군데서 문제가 생긴다. 서버에는 window 가 없어서 hydration 이 어긋나고, 동시 렌더링 중간에 값이 바뀌면 한 화면 안에서 컴포넌트마다 값이 다르게 보일 수 있다(tearing). useSyncExternalStore 는 이걸 React 가 알아서 맞춰 주도록 만든 훅이다.

인자 세 개

subscribe 는 값이 바뀔 때 불릴 callback 을 등록하고 해제 함수를 돌려준다. getSnapshot 은 지금 값을 돌려주는데, 상태가 안 바뀌었으면 같은 값을 돌려줘야 한다. getServerSnapshot 은 서버 렌더링과 hydration 때 쓸 값이고, 클라이언트 전용이면 빼도 된다.

const snapshot = useSyncExternalStore(
  subscribe,          // (callback) => unsubscribe
  getSnapshot,        // () => 현재 값
  getServerSnapshot?  // () => SSR 용 값 (선택)
);

동작 흐름

처음에 getSnapshot 으로 값을 읽고 subscribe 로 변경을 기다린다. 외부 값이 바뀌어 callback 이 불리면 React 가 getSnapshot 을 다시 부르고, 이전 값과 Object.is 로 비교해서 다를 때만 다시 렌더한다.

예제: 화면 너비
function subscribe(callback: () => void) {
  window.addEventListener('resize', callback);
  return () => window.removeEventListener('resize', callback);
}

function getSnapshot() {
  return window.innerWidth;
}

function getServerSnapshot() {
  return 0; // SSR 기본값
}

function useWindowWidth() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
예제: 간단한 외부 스토어
let count = 0;
const listeners = new Set<() => void>();

const store = {
  subscribe(cb: () => void) {
    listeners.add(cb);
    return () => listeners.delete(cb);
  },
  getSnapshot() {
    return count;
  },
  increment() {
    count += 1;
    listeners.forEach((cb) => cb());
  },
};

function Counter() {
  const n = useSyncExternalStore(store.subscribe, store.getSnapshot);
  return <button onClick={() => store.increment()}>{n}</button>;
}

어디에 쓰이나

직접 쓸 일은 많지 않다. Zustand 나 react-redux 같은 상태 라이브러리가 안에서 이 훅을 쓰고 있고, 직접 쓴다면 matchMedia, online, visibility 같은 브라우저 값이나 React 밖 이벤트 버스를 구독할 때 정도다. 회사 코드베이스에도 직접 쓴 곳은 없고 라이브러리 안에서만 돌고 있었다.

조심할 것

getSnapshot 에서 매번 새 객체를 만들어 돌려주면 매번 다른 값으로 보여서 무한 리렌더가 난다. 객체를 돌려줘야 하면 캐시해 두고 값이 바뀔 때만 새로 만든다. Next.js 에서 window 같은 브라우저 값을 읽을 때는 getServerSnapshot 을 넣는 게 안전하다.

정리하면 useEffect 는 mount 뒤 부수 효과, use 는 Promise 나 Context 읽기, useSyncExternalStore 는 React 밖 스토어 구독이다.