useSyncExternalStore는 외부 스토어를 구독할 수 있게 해주는 React Hook이에요.
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) {#usesyncexternalstore}컴포넌트의 최상위 레벨에서 useSyncExternalStore를 호출해서 외부 데이터 스토어에서 값을 읽을 수 있어요.
import { useSyncExternalStore } from 'react';
import { todosStore } from './todoStore.js';
function TodosApp() {
const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);
// ...
}
이 Hook은 스토어에 있는 데이터의 스냅샷을 반환해요. 인자로 두 개의 함수를 전달해야 해요:
subscribe 함수는 스토어를 구독하고, 구독을 해제하는 함수를 반환해야 해요.getSnapshot 함수는 스토어에서 데이터의 스냅샷을 읽어야 해요.subscribe: 단일 callback 인자를 받아서 스토어에 구독하는 함수예요. 스토어가 변경되면, 제공된 callback을 호출해야 하고, 그러면 React가 getSnapshot을 다시 호출하고 (필요하다면) 컴포넌트를 리렌더링해요. subscribe 함수는 구독을 정리하는 함수를 반환해야 해요.
getSnapshot: 컴포넌트에 필요한 스토어 데이터의 스냅샷을 반환하는 함수예요. 스토어가 변경되지 않았다면, getSnapshot을 반복 호출해도 같은 값을 반환해야 해요. 스토어가 변경되어 반환 값이 달라지면(Object.is로 비교), React가 컴포넌트를 리렌더링해요.
선택적 getServerSnapshot: 스토어에 있는 데이터의 초기 스냅샷을 반환하는 함수예요. 서버 렌더링 중과 클라이언트에서 서버 렌더링된 콘텐츠를 hydration하는 동안에만 사용돼요. 서버 스냅샷은 클라이언트와 서버 사이에서 동일해야 하고, 보통 직렬화되어 서버에서 클라이언트로 전달돼요. 이 인자를 생략하면, 서버에서 컴포넌트를 렌더링할 때 에러가 발생해요.
렌더링 로직에서 사용할 수 있는 스토어의 현재 스냅샷이에요.
getSnapshot이 반환하는 스토어 스냅샷은 불변(immutable)해야 해요. 기반 스토어에 변경 가능한(mutable) 데이터가 있다면, 데이터가 변경되었을 때 새로운 불변 스냅샷을 반환하세요. 그렇지 않으면, 캐시된 마지막 스냅샷을 반환하세요.
리렌더링 중에 다른 subscribe 함수가 전달되면, React는 새로 전달된 subscribe 함수를 사용해서 스토어에 다시 구독해요. subscribe를 컴포넌트 외부에 선언해서 이걸 방지할 수 있어요.
논블로킹 Transition 업데이트 중에 스토어가 변경되면, React는 해당 업데이트를 블로킹으로 수행하는 것으로 폴백해요. 구체적으로, 모든 Transition 업데이트에 대해 React는 DOM에 변경사항을 적용하기 직전에 getSnapshot을 두 번째로 호출해요. 처음 호출했을 때와 다른 값을 반환하면, React는 업데이트를 처음부터 다시 시작하고, 이번에는 블로킹 업데이트로 적용해서 화면의 모든 컴포넌트가 같은 버전의 스토어를 반영하도록 해요.
useSyncExternalStore가 반환하는 스토어 값을 기반으로 렌더링을 중단(suspend) 하는 것은 권장하지 않아요. 그 이유는 외부 스토어에 대한 변경은 논블로킹 Transition 업데이트로 표시할 수 없기 때문에, 가장 가까운 Suspense 폴백을 트리거해서 이미 렌더링된 화면의 콘텐츠를 로딩 스피너로 대체하게 되는데, 이건 보통 좋지 않은 UX를 만들어요.
예를 들어, 다음과 같은 코드는 권장하지 않아요:
const LazyProductDetailPage = lazy(() => import('./ProductDetailPage.js'));
function ShoppingApp() {
const selectedProductId = useSyncExternalStore(...);
// ❌ `selectedProductId`에 의존하는 Promise와 함께 `use`를 호출
const data = use(fetchItem(selectedProductId))
// ❌ `selectedProductId`를 기반으로 lazy 컴포넌트를 조건부 렌더링
return selectedProductId != null ? <LazyProductDetailPage /> : <FeaturedProducts />;
}
대부분의 React 컴포넌트는 props, state, context에서만 데이터를 읽어요. 하지만 가끔 컴포넌트가 시간이 지남에 따라 변경되는 React 외부의 스토어에서 데이터를 읽어야 할 때가 있어요. 여기에는 다음이 포함돼요:
컴포넌트의 최상위 레벨에서 useSyncExternalStore를 호출해서 외부 데이터 스토어에서 값을 읽으세요.
import { useSyncExternalStore } from 'react';
import { todosStore } from './todoStore.js';
function TodosApp() {
const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);
// ...
}
이 Hook은 스토어에 있는 데이터의 스냅샷을 반환해요. 인자로 두 개의 함수를 전달해야 해요:
subscribe 함수는 스토어를 구독하고, 구독을 해제하는 함수를 반환해야 해요.getSnapshot 함수는 스토어에서 데이터의 스냅샷을 읽어야 해요.React는 이 함수들을 사용해서 컴포넌트가 스토어를 구독한 상태로 유지하고, 변경이 있을 때 리렌더링해요.
예를 들어, 아래 샌드박스에서 todosStore는 React 외부에 데이터를 저장하는 외부 스토어로 구현되어 있어요. TodosApp 컴포넌트는 useSyncExternalStore Hook을 사용해서 그 외부 스토어에 연결해요.
import { useSyncExternalStore } from 'react';
import { todosStore } from './todoStore.js';
export default function TodosApp() {
const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);
return (
<>
<button onClick={() => todosStore.addTodo()}>Add todo</button>
<hr />
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</>
);
}
// src/todoStore.js
// This is an example of a third-party store
// that you might need to integrate with React.
// If your app is fully built with React,
// we recommend using React state instead.
let nextId = 0;
let todos = [{ id: nextId++, text: 'Todo #1' }];
let listeners = [];
export const todosStore = {
addTodo() {
todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }]
emitChange();
},
subscribe(listener) {
listeners = [...listeners, listener];
return () => {
listeners = listeners.filter(l => l !== listener);
};
},
getSnapshot() {
return todos;
}
};
function emitChange() {
for (let listener of listeners) {
listener();
}
}
참고
가능하다면,
useState와useReducer를 사용해서 React의 내장 state를 사용하는 것을 권장해요.useSyncExternalStoreAPI는 기존의 비-React 코드와 통합해야 할 때 주로 유용해요.
useSyncExternalStore를 추가하는 또 다른 이유는 시간이 지남에 따라 변경되는 브라우저가 노출하는 어떤 값을 구독하고 싶을 때예요. 예를 들어, 컴포넌트가 네트워크 연결이 활성화되어 있는지 표시하고 싶다고 해봐요. 브라우저는 이 정보를 navigator.onLine이라는 프로퍼티를 통해 노출해요.
이 값은 React가 모르는 사이에 변경될 수 있어서, useSyncExternalStore로 읽어야 해요.
import { useSyncExternalStore } from 'react';
function ChatIndicator() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
// ...
}
getSnapshot 함수를 구현하려면, 브라우저 API에서 현재 값을 읽으세요:
function getSnapshot() {
return navigator.onLine;
}
다음으로, subscribe 함수를 구현해야 해요. 예를 들어, navigator.onLine이 변경되면, 브라우저는 window 객체에서 online과 offline 이벤트를 발생시켜요. callback 인자를 해당 이벤트에 구독하고, 그런 다음 구독을 정리하는 함수를 반환해야 해요:
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
이제 React는 외부 navigator.onLine API에서 값을 읽는 방법과 그 변경을 구독하는 방법을 알게 됐어요. 네트워크에서 기기를 분리해보면 컴포넌트가 반응해서 리렌더링되는 걸 확인할 수 있어요:
import { useSyncExternalStore } from 'react';
export default function ChatIndicator() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}
function getSnapshot() {
return navigator.onLine;
}
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
보통 컴포넌트에서 useSyncExternalStore를 직접 작성하지는 않을 거예요. 대신, 보통 자신만의 커스텀 Hook에서 호출하게 될 거예요. 이렇게 하면 다른 컴포넌트들에서 같은 외부 스토어를 사용할 수 있어요.
예를 들어, 이 커스텀 useOnlineStatus Hook은 네트워크가 온라인인지 추적해요:
import { useSyncExternalStore } from 'react';
export function useOnlineStatus() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
return isOnline;
}
function getSnapshot() {
// ...
}
function subscribe(callback) {
// ...
}
이제 다른 컴포넌트들이 기반 구현을 반복하지 않고 useOnlineStatus를 호출할 수 있어요:
import { useOnlineStatus } from './useOnlineStatus.js';
function StatusBar() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}
function SaveButton() {
const isOnline = useOnlineStatus();
function handleSaveClick() {
console.log('✅ Progress saved');
}
return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? 'Save progress' : 'Reconnecting...'}
</button>
);
}
export default function App() {
return (
<>
<SaveButton />
<StatusBar />
</>
);
}
// src/useOnlineStatus.js
import { useSyncExternalStore } from 'react';
export function useOnlineStatus() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
return isOnline;
}
function getSnapshot() {
return navigator.onLine;
}
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
React 앱이 서버 렌더링을 사용한다면, React 컴포넌트가 브라우저 환경 외부에서도 실행되어 초기 HTML을 생성해요. 이건 외부 스토어에 연결할 때 몇 가지 문제를 만들어요:
이런 문제를 해결하려면, useSyncExternalStore의 세 번째 인자로 getServerSnapshot 함수를 전달하세요:
import { useSyncExternalStore } from 'react';
export function useOnlineStatus() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return isOnline;
}
function getSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true; // 서버에서 생성된 HTML에서는 항상 "Online"을 표시
}
function subscribe(callback) {
// ...
}
getServerSnapshot 함수는 getSnapshot과 비슷하지만, 두 가지 상황에서만 실행돼요:
이를 통해 앱이 인터랙티브해지기 전에 사용될 초기 스냅샷 값을 제공할 수 있어요. 서버 렌더링에 의미 있는 초기 값이 없다면, 이 인자를 생략해서 클라이언트에서 렌더링을 강제하세요.
참고
getServerSnapshot이 초기 클라이언트 렌더링에서 서버에서 반환한 것과 정확히 같은 데이터를 반환하는지 확인하세요. 예를 들어,getServerSnapshot이 서버에서 미리 채워진 스토어 콘텐츠를 반환했다면, 이 콘텐츠를 클라이언트로 전송해야 해요. 이를 수행하는 한 가지 방법은 서버 렌더링 중에window.MY_STORE_DATA같은 전역을 설정하는<script>태그를 생성하고, 클라이언트의getServerSnapshot에서 그 전역을 읽는 거예요. 외부 스토어가 이를 수행하는 방법에 대한 지침을 제공해야 해요.
getSnapshot의 결과는 캐시되어야 합니다" 에러가 발생해요 {#im-getting-an-error-the-result-of-getsnapshot-should-be-cached}이 에러는 getSnapshot 함수가 호출될 때마다 새 객체를 반환한다는 의미예요. 예를 들어:
function getSnapshot() {
// 🔴 getSnapshot에서 항상 다른 객체를 반환하지 마세요
return {
todos: myStore.todos
};
}
getSnapshot 반환 값이 마지막과 다르면 React는 컴포넌트를 리렌더링해요. 그래서 항상 다른 값을 반환하면 무한 루프에 빠지고 이 에러가 발생해요.
getSnapshot 객체는 실제로 무언가가 변경되었을 때만 다른 객체를 반환해야 해요. 스토어가 불변 데이터를 포함하고 있다면, 그 데이터를 직접 반환할 수 있어요:
function getSnapshot() {
// ✅ 불변 데이터는 반환할 수 있어요
return myStore.todos;
}
스토어 데이터가 변경 가능하다면, getSnapshot 함수는 불변 스냅샷을 반환해야 해요. 이건 새 객체를 생성해야 한다는 의미지만, 매번 호출할 때마다 생성해서는 안 돼요. 대신, 마지막으로 계산된 스냅샷을 저장하고, 스토어의 데이터가 변경되지 않았다면 마지막과 같은 스냅샷을 반환해야 해요. 변경 가능한 데이터가 변경되었는지 판단하는 방법은 변경 가능한 스토어에 따라 달라요.
subscribe 함수가 매 리렌더링마다 호출돼요 {#my-subscribe-function-gets-called-after-every-re-render}이 subscribe 함수는 컴포넌트 안에 정의되어 있어서 매 리렌더링마다 다른 함수가 돼요:
function ChatIndicator() {
// 🚩 항상 다른 함수라서 React가 매 리렌더링마다 다시 구독해요
function subscribe() {
// ...
}
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
// ...
}
리렌더링 사이에 다른 subscribe 함수를 전달하면 React가 스토어에 다시 구독해요. 이게 성능 문제를 일으키고 다시 구독하는 걸 피하고 싶다면, subscribe 함수를 외부로 옮기세요:
// ✅ 항상 같은 함수라서 React가 다시 구독할 필요가 없어요
function subscribe() {
// ...
}
function ChatIndicator() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
// ...
}
또는, subscribe를 useCallback으로 감싸서 어떤 인자가 변경될 때만 다시 구독하게 할 수 있어요:
function ChatIndicator({ userId }) {
// ✅ userId가 변경되지 않는 한 같은 함수
const subscribe = useCallback(() => {
// ...
}, [userId]);
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
// ...
}