useSyncExternalStore는 React 바깥에 있는 데이터를 컴포넌트가 구독하게 하고, 그 데이터가 바뀔 때마다 다시 렌더링합니다. 두 함수를 넘깁니다. 변경을 듣는 방법을 React에 알려 주는 subscribe와, 현재 값을 반환하는 getSnapshot입니다.
두 Display 컴포넌트는 props도 컨텍스트도 공유하지 않는데, 같은 스토어를 구독하기 때문에 함께 업데이트됩니다. 버튼은 React setter가 아니라 일반 함수를 호출합니다. 세 번째 <Display name="Sidebar" />를 추가하면 다른 변경 없이 함께 동작합니다.
문법
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
subscribe(callback)은 듣기를 시작하고, 데이터가 바뀌었을 수 있을 때마다callback을 호출하며, 구독 해제 함수를 반환합니다. React는 컴포넌트가 마운트된 뒤 이것을 호출하고, 언마운트될 때 반환된 함수를 호출합니다.getSnapshot()은 현재 값을 반환합니다. React는 렌더링마다, 그리고 각 알림 후에 이것을 호출한 뒤Object.is로 이전 결과와 비교합니다. 같은 값이면 렌더링하지 않습니다.getServerSnapshot()(선택 사항)은 서버와 하이드레이션 중에 쓸 값을 반환합니다.
subscribe는 컴포넌트 바깥에 정의하거나 useCallback으로 안정적으로 유지하세요. 렌더링마다 새 subscribe 함수를 넘기면 React는 매번 구독을 해제했다가 다시 구독합니다.
스토어로서의 브라우저 API
현재 값이 있고 값이 바뀔 때 이벤트를 발생시키는 것은 무엇이든 이 형태에 맞습니다. online, offline 이벤트를 가진 navigator.onLine이 대표적인 예입니다.
네트워크를 끄면(또는 브라우저 개발자 도구에서 Offline으로 바꾸면) 새로고침 없이 텍스트가 바뀝니다. 세 번째 인자는 navigator가 없는 서버에서 렌더링할 때 "온라인이라고 가정하라"고 말합니다. 훅 호출을 useOnlineStatus로 감싸면 어떤 컴포넌트든 쓸 수 있는 커스텀 훅이 됩니다.
창 너비도 같은 방식으로 동작합니다.
사본을 몇 개 추가하고 창 크기를 바꿔 보세요. 모든 사본이 같은 순간에 같은 숫자를 보여 줍니다. 각 사본은 자기 리스너를 가지고 렌더링 중에 너비를 읽으므로, 어느 것도 한 프레임 뒤처지지 않습니다.
getSnapshot은 캐시된 값을 반환해야 합니다
React는 getSnapshot을 자주 호출하고 결과를 참조로 비교합니다. 호출마다 새 객체나 배열을 만드는 함수는 항상 변경된 것처럼 보입니다.
// Broken: a new object on every call
function getSnapshot() {
return { count: store.count, user: store.user };
}
// Also broken: filter returns a new array every time
function getSnapshot() {
return store.todos.filter((t) => !t.done);
}
React는 렌더링하고, getSnapshot을 호출하고, "다른" 값을 받고, 다시 렌더링하기를 반복하다가 "Maximum update depth exceeded"와 함께 멈춥니다. 개발 환경에서는 React가 먼저 "The result of getSnapshot should be cached to avoid an infinite loop"도 기록합니다. 여기 미리보기 같은 프로덕션 빌드는 그 경고를 건너뛰고 마지막 오류만 짧은 코드(Minified React error #185)로 보고합니다.
해결 방법은 스토어의 데이터를 불변으로 유지하는 것입니다. 데이터가 바뀌면 객체를 교체하고, 저장된 참조를 그대로 반환하세요.
getSnapshot은 add가 교체할 때까지 같은 state 객체를 반환하므로, 컴포넌트는 변경마다 한 번씩 렌더링됩니다. 필터링은 스냅샷을 읽은 뒤 컴포넌트에서 하므로 안전합니다. 대신 스토어 안에서 필터링하려면, 데이터가 바뀔 때 필터링된 배열을 계산해서 저장해 두고 getSnapshot이 저장된 사본을 반환하게 하세요.
getServerSnapshot과 하이드레이션
서버에는 window도, navigator도, 구독도 없습니다. 세 번째 인자가 그곳에서 무엇을 렌더링할지 React에 알려 줍니다.
const width = useSyncExternalStore(
subscribe,
() => window.innerWidth, // in the browser
() => 1024 // on the server, and during hydration
);
React는 서버 HTML을 하이드레이션할 때 브라우저의 첫 렌더링에도 getServerSnapshot을 사용하므로 둘이 일치합니다. 하이드레이션 직후 getSnapshot을 읽고, 실제 값이 다르면 그 값으로 다시 렌더링합니다. 세 번째 인자가 없으면 서버 렌더링이 "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering."을 던집니다. 컴포넌트 위에 <Suspense> 경계가 있으면 서버는 그 경계의 대체 UI를 보내고 브라우저가 대신 내용을 렌더링하며, 경계가 없으면 서버 렌더링이 실패합니다.
useSyncExternalStore와 useEffect + useState
이펙트로도 구독할 수 있습니다.
function useOnlineStatus() {
const [online, setOnline] = useState(true);
useEffect(() => {
const update = () => setOnline(navigator.onLine);
update();
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []);
return online;
}
동작은 하지만 약점이 두 가지 있습니다. 첫 렌더링은 항상 초기 추정값을 보여 주고, 실제 값은 이펙트가 실행된 뒤 한 렌더링 늦게 도착합니다. 그리고 동시성 렌더링(예를 들어 트랜지션 중)에서는 React가 렌더링을 중간에 멈출 수 있는데, 멈춘 동안 스토어가 바뀌면 그 전과 후에 렌더링된 컴포넌트가 서로 다른 값을 보여 줄 수 있습니다. 이 불일치를 tearing이라고 합니다. useSyncExternalStore는 렌더링 중에 값을 읽고, 스토어가 바뀌었다면 React가 렌더링을 동기적으로 다시 하게 하므로 모든 컴포넌트가 같은 값을 봅니다.
데이터가 React 바깥에 있을 때 사용하세요. 직접 만든 스토어 모듈, 브라우저 API, 서드파티 라이브러리가 그렇습니다. 대부분의 상태 관리 라이브러리(Redux, Zustand 등)는 자기 훅 안에서 이것을 대신 호출합니다. 컴포넌트에 속한 데이터에는 여전히 useState, useReducer, 컨텍스트가 맞는 도구입니다.
자주 묻는 질문
useSyncExternalStore는 어디에 쓰나요?
React가 소유하지 않고 스스로 바뀔 수 있는 데이터를 읽을 때 씁니다. React 바깥에서 작성한 스토어, 서드파티 상태 관리 라이브러리, navigator.onLine이나 창 너비 같은 브라우저 값이 그렇습니다. 스토어가 React에 바뀌었다고 알릴 때마다 컴포넌트가 다시 렌더링됩니다.
subscribe와 getSnapshot은 무엇을 하나요?
subscribe(callback)은 스토어를 듣기 시작하고, 변경될 때마다 callback을 호출하며, 듣기를 멈추는 함수를 반환합니다. getSnapshot()은 현재 값을 반환합니다. React는 렌더링 중과 각 알림 후에 getSnapshot을 호출하고, Object.is 기준으로 값이 바뀐 경우에만 다시 렌더링합니다.
getSnapshot이 캐시된 값을 반환해야 하는 이유는 무엇인가요?
React는 getSnapshot 호출 결과를 매번 이전 결과와 비교합니다. 매번 새 객체나 배열을 반환하면 React는 항상 변경을 보게 되어, 다시 렌더링하고, 다시 getSnapshot을 호출하고, "Maximum update depth exceeded"를 던질 때까지 반복합니다. 데이터가 정말로 바뀔 때까지 같은 참조를 반환하세요.
getServerSnapshot이란 무엇인가요?
선택적인 세 번째 인자입니다. 서버 렌더링 중과 브라우저의 하이드레이션 중에 쓸 값을 반환하므로, 양쪽이 같은 HTML을 만듭니다. 없으면 서버에서 컴포넌트가 "Missing getServerSnapshot"을 던지고, 가장 가까운 <Suspense> 경계 아래의 내용은 대신 브라우저에서 렌더링됩니다.
useSyncExternalStore와 useEffect + useState 중 무엇을 써야 하나요?
외부 데이터를 구독할 때는 useSyncExternalStore를 선호하세요. 렌더링 중에 값을 읽으므로 첫 렌더링부터 올바르고, 동시성 렌더링 중에도 모든 컴포넌트가 같은 값을 봅니다. useEffect와 useState를 쓰면 한 번은 낡은 값으로 렌더링하고, 잠깐 동안 컴포넌트마다 다른 값을 보여 줄 수 있습니다.