리액트 공식 문서를 참고한 정리 내용 (25.08 기준)
React 컴포넌트가 외부 데이터 저장소(store) 의 변화를 안전하게 감지하고 값을 읽을 수 있도록 도와주는 Hook이다.
쉽게 말해, Redux, Zustand 같은 전역 상태 관리 라이브러리와 React를 연결할 때 자주 사용된다.
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
컴포넌트의 최상위 레벨에서 useSyncExternalStore를 호출하여 외부 데이터 저장소에서 값을 읽는다.
import { useSyncExternalStore } from 'react';
import { todosStore } from './todoStore.js';
function TodosApp() {
const todos = useSyncExternalStore(
todosStore.subscribe, // 변경사항 구독
todosStore.getSnapshot // 현재 데이터 가져오기
);
// todos 데이터를 화면에 표시
}
🔎 왜 필요할까?
일반적으로 컴포넌트의 state는useState나useReducer로 관리하지만, 여러 컴포넌트에서 같은 외부 저장소를 바라보는 경우에 문제가 생긴다.이때
useSyncExternalStore를 사용하면 React가 외부 상태의 최신 값을 정확하게 반영되도록 보장하며, 특히 Concurrent Rendering 홤경에서 안전하게 동작한다.Concurrent Rendering이 뭐냐면?
기존 React는 한 번 렌더링을 시작하면 멈추지 않고 끝까지 실행해서, 화면이 복잡하거나 데이터가 많으면 멈춘 듯한 느낌이 들었다.
이러한 현상을 해결하기 위해 React 18의 Concurrent Rendering은 렌더링을 쪼개서 처리할 수 있게 해준다.
- 급한 작업(사용자 입력 같은 것)을 먼저 처리하고,
- 덜 급한 작업(화면 전체 렌더링 같은 것)은 뒤로 미룰 수 있다
즉, 더 부드럽고 끊기지 않는 사용자 경험을 주는 것
🔗 왜
useSyncExternalStore랑 연결될까?외부 저장소에서 데이터를 가져올 때, React가 동시 렌더링을 해도 데이터 불일치가 생기면 안 되기 때문에,
useSyncExternalStore는 Concurrent Rendering 환경에서도 안정적으로 동작하도록 설계된 훅이다.
subscribe“저장소가 바뀌면 알려줘!” 라고 구독하는 함수이다.
callback 하나를 인수로 받는다.callback을 실행해서 React가 다시 getSnapshot을 호출하게 만든다.⇒ 변화를 감지하고 알리는 역할
getSnapshot“지금 저장소 값이 뭐야?” 하고 현재 상태(스냅샷)를 가져오는 함수이다
Object.is로 비교해서 컴포넌트를 다시 렌더링한다.⇒ getSnapshot은 현재 데이터를 가져오는 역할
getServerSnapshot(optional)서버 렌더링(SSR) 시에서만 사용되는 스냅샷 함수
⇒ getServerSnapshot은 서버에서 초기 데이터를 맞춰주는 역할
store의 현재 상태(스냅샷)을 반환하며, 이걸 바로 컴포넌트 렌더링에 사용할 수 있다.
getSnapshot은 항상 최신 스냅샷을 반환해야 한다.
getSnapshot이 반환하는 값은 불변해야 한다.
getSnapshot이 예전 값을 돌려주면, React는 변경을 놓치게 된다.subscribe는 렌더링마다 새로 구독됨subscribe를 다시 실행해서 저장소 변화를 추적한다.subscribe를 컴포넌트 외부에서 선언해야 불필요한 중복 구독을 막을 수 있다.React 18부터는 startTransition 같은 비차단(non-blocking) 업데이트가 있다.
👉 쉽게 말해, store 변화는 우선순위가 높아서 무조건 반영된다는 뜻!
Suspense fallback과 함께 쓰면 UX가 나빠질 수 있음Suspense fallback(로딩 스피너 같은 것)을 띄울 수 있다.// ❌ 나쁜 예시
function ShoppingApp() {
const selectedProductId = useSyncExternalStore(...);
// ❌ store 값으로 바로 데이터 fetch 시도
const data = use(fetchItem(selectedProductId));
// ❌ store 값으로 바로 조건부 렌더링
return selectedProductId != null
? <LazyProductDetailPage />
: <FeaturedProductPage />;
}
런 경우 React가 store 업데이트 중에 화면을 멈췄다가 Suspense fallback을 띄워버릴 수 있어서 부드럽지 않음.
상태 관리 라이브러리(store)를 React 컴포넌트에서 읽어야 한다면 useSyncExternalStore를 사용한다.
import { useSyncExternalStore } from 'react';
import { todosStore } from './todoStore.js';
function TodosApp() {
const todos = useSyncExternalStore(
todosStore.subscribe,
todosStore.getSnapshot
);
return <ul>{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul>;
}
컴포넌트는 항상 store의 최신 데이터를 가져오고, store가 업데이트되면 자동으로 리렌더링한다. 쉽게 말하면 "store에 연결해서, 데이터가 바뀌면 화면도 바뀌도록 한다”
브라우저 값(navigator.onLine 등)이 시간에 따라 바뀔 수 있으니, React가 제대로 감지하도록 useSyncExternalStore로 두독해야 한다.
예를 들어 네트워크 연결 여부(navigator.onLine)를 추적할 때 사용할 수 있다.
import { useSyncExternalStore } from 'react';
function ChatIndicator() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
return <h1>{isOnline ? '🟢 온라인' : '🔴 오프라인'}</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);
};
}
getSnapshot: 지금 온라인인지/오프라인인지 알려줌subscribe: 온라인/오프라인 이벤트를 감지해서 React에 알림👉 “브라우저 네트워크 상태 변화를 감지해서 화면에 표시한다”
추가로 useSyncExternalStore는 외부 저장소를 사용할 수 있게 해주는 Hook이기 떄문에, 일반적으로 커스텀 훅에서 주로 호출한다.
React 앱이 서버 렌더링(SSR)을 사용하는 경우 브라우저 외부에서도 HTML이 먼저 생성되어, 이로 인해 외부 store와 연결하려고 하면 문제가 생길 수 있다.
navigator.onLine)는 서버엔 없어서 동작 ❌이러한 문제를 해결하기 위해서는 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" 표시
}
getServerSnapshot은 getSnapshot과 유사하지만 서버에서 실행된다.
(1) 서버에서 HTML 생성할 때
(2) 클라이언트 hydration(서버 HTML을 가져와 인터랙티브하게 만드는 과정) 중
즉, 이 매개변수는 초기 HTML과 클라이언트 렌더링이 일치하도록 보장하는 게 목적!
⚠️ 중요한 점은
getServerSnapshot이 반환하는 값은 클라이언트 초기 렌더링 값과 반드시 동일해야 한다.
(ex. 서버에서 미리store데이터를 채웠다면, 이를 클라이언트로 넘겨주어야 함)
<script>
window.MY_STORE_DATA = {...};
</script>
위처럼 서버 렌더링 중에 클라이언트에서는 getServerSnaspshot에서 window.MY_STORE_DATA를 읽어오도록 작성한다.
이처럼 getServerSnapshot은 SSR 시점에서 사용할 안전한 초기 스냅샷을 제공해 서버와 클라이언트의 데이터를 일치시켜 주는 함수이다.
getSnapshot에서 매번 새로운 객체를 반환하면 안 됨getSnapshot 함수가 호출될 때마다 새 객체를 반환하면 React는 “값이 변경됐다”고 착각해서 계속 리렌더링을 발생시켜, 무한 루프 에러가 발생할 가능성이 생긴다.
그러니 실제로 값이 변하지 않았다면 같은 참조(동일한 값)를 반환해야 한다.
// 좋아용~
function getSnapshot() {
return myStore.todos; // 항상 같은 참조 반환
}
// ❌
function getSnapshot() {
return { todos: myStore.todos }; // 매번 새로운 객체 생성
}
getSnapshot은 캐시된 값을 반환해야 하며, 변경되지 않았을 땐 동일한 참조를 반환해야 한다.
subscribe가 매번 새로 생성되면 안 됨subscribe 함수가 컴포넌트 안에서 정의되면 렌더링 때마다 새로운 함수가 생성되어, React는 “새로운 구독 함수네?” 하고 매번 store를 다시 구독해서 성능 문제가 생길 수 있다.
그러니
(1) subscribe를 컴포넌트 외부에 정의하고 혹은
(2) useCallback으로 감싸서 같은 함수를 유지한다.
// 컴포넌트 외부에 정의
function subscribe(callback) {
myStore.subscribe(callback);
return () => myStore.unsubscribe(callback);
}
function ChatIndicator() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
return <p>{isOnline ? "Online" : "Offline"}</p>;
}
// useCallback 사용
function ChatIndicator({ userId }) {
const subscribe = useCallback((callback) => {
myStore.subscribeUser(userId, callback);
return () => myStore.unsubscribeUser(userId, callback);
}, [userId]);
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
return <p>{isOnline ? "Online" : "Offline"}</p>;
}
subscribe는 항상 동일한 함수여야 불필요한 재구독을 막을 수 있다.
구체적으로 설명해 주셔서 이해가 잘 됐어요~ 감사합니다👍👍