useSyncExternalStore

김동현·2026년 3월 17일

useSyncExternalStore

소개

useSyncExternalStore는 외부 스토어를 구독할 수 있게 해주는 React Hook이에요.

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

목차


레퍼런스 {#reference}

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은 스토어에 있는 데이터의 스냅샷을 반환해요. 인자로 두 개의 함수를 전달해야 해요:

  1. subscribe 함수는 스토어를 구독하고, 구독을 해제하는 함수를 반환해야 해요.
  2. getSnapshot 함수는 스토어에서 데이터의 스냅샷을 읽어야 해요.

아래에서 더 많은 예제를 확인하세요.

매개변수 {#parameters}

  • subscribe: 단일 callback 인자를 받아서 스토어에 구독하는 함수예요. 스토어가 변경되면, 제공된 callback을 호출해야 하고, 그러면 React가 getSnapshot을 다시 호출하고 (필요하다면) 컴포넌트를 리렌더링해요. subscribe 함수는 구독을 정리하는 함수를 반환해야 해요.

  • getSnapshot: 컴포넌트에 필요한 스토어 데이터의 스냅샷을 반환하는 함수예요. 스토어가 변경되지 않았다면, getSnapshot을 반복 호출해도 같은 값을 반환해야 해요. 스토어가 변경되어 반환 값이 달라지면(Object.is로 비교), React가 컴포넌트를 리렌더링해요.

  • 선택적 getServerSnapshot: 스토어에 있는 데이터의 초기 스냅샷을 반환하는 함수예요. 서버 렌더링 중과 클라이언트에서 서버 렌더링된 콘텐츠를 hydration하는 동안에만 사용돼요. 서버 스냅샷은 클라이언트와 서버 사이에서 동일해야 하고, 보통 직렬화되어 서버에서 클라이언트로 전달돼요. 이 인자를 생략하면, 서버에서 컴포넌트를 렌더링할 때 에러가 발생해요.

반환값 {#returns}

렌더링 로직에서 사용할 수 있는 스토어의 현재 스냅샷이에요.

주의사항 {#caveats}

  • 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 />;
    }

사용법 {#usage}

외부 스토어 구독하기 {#subscribing-to-an-external-store}

대부분의 React 컴포넌트는 props, state, context에서만 데이터를 읽어요. 하지만 가끔 컴포넌트가 시간이 지남에 따라 변경되는 React 외부의 스토어에서 데이터를 읽어야 할 때가 있어요. 여기에는 다음이 포함돼요:

  • React 외부에 state를 저장하는 서드파티 상태 관리 라이브러리
  • 변경 가능한 값과 그 변경을 구독하는 이벤트를 노출하는 브라우저 API

컴포넌트의 최상위 레벨에서 useSyncExternalStore를 호출해서 외부 데이터 스토어에서 값을 읽으세요.

import { useSyncExternalStore } from 'react';
import { todosStore } from './todoStore.js';

function TodosApp() {
  const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);
  // ...
}

이 Hook은 스토어에 있는 데이터의 스냅샷을 반환해요. 인자로 두 개의 함수를 전달해야 해요:

  1. subscribe 함수는 스토어를 구독하고, 구독을 해제하는 함수를 반환해야 해요.
  2. 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();
  }
}

참고

가능하다면, useStateuseReducer를 사용해서 React의 내장 state를 사용하는 것을 권장해요. useSyncExternalStore API는 기존의 비-React 코드와 통합해야 할 때 주로 유용해요.


브라우저 API 구독하기 {#subscribing-to-a-browser-api}

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 객체에서 onlineoffline 이벤트를 발생시켜요. 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);
  };
}

로직을 커스텀 Hook으로 추출하기 {#extracting-the-logic-to-a-custom-hook}

보통 컴포넌트에서 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);
  };
}

서버 렌더링 지원 추가하기 {#adding-support-for-server-rendering}

React 앱이 서버 렌더링을 사용한다면, React 컴포넌트가 브라우저 환경 외부에서도 실행되어 초기 HTML을 생성해요. 이건 외부 스토어에 연결할 때 몇 가지 문제를 만들어요:

  • 브라우저 전용 API에 연결하고 있다면, 서버에는 존재하지 않기 때문에 작동하지 않아요.
  • 서드파티 데이터 스토어에 연결하고 있다면, 서버와 클라이언트 사이에서 데이터가 일치해야 해요.

이런 문제를 해결하려면, 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과 비슷하지만, 두 가지 상황에서만 실행돼요:

  • HTML을 생성할 때 서버에서 실행돼요.
  • hydration 중에 클라이언트에서 실행돼요. 즉, React가 서버 HTML을 가져와서 인터랙티브하게 만들 때요.

이를 통해 앱이 인터랙티브해지기 전에 사용될 초기 스냅샷 값을 제공할 수 있어요. 서버 렌더링에 의미 있는 초기 값이 없다면, 이 인자를 생략해서 클라이언트에서 렌더링을 강제하세요.

참고

getServerSnapshot이 초기 클라이언트 렌더링에서 서버에서 반환한 것과 정확히 같은 데이터를 반환하는지 확인하세요. 예를 들어, getServerSnapshot이 서버에서 미리 채워진 스토어 콘텐츠를 반환했다면, 이 콘텐츠를 클라이언트로 전송해야 해요. 이를 수행하는 한 가지 방법은 서버 렌더링 중에 window.MY_STORE_DATA 같은 전역을 설정하는 <script> 태그를 생성하고, 클라이언트의 getServerSnapshot에서 그 전역을 읽는 거예요. 외부 스토어가 이를 수행하는 방법에 대한 지침을 제공해야 해요.


문제 해결 {#troubleshooting}

"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);
  // ...
}

또는, subscribeuseCallback으로 감싸서 어떤 인자가 변경될 때만 다시 구독하게 할 수 있어요:

function ChatIndicator({ userId }) {
  // ✅ userId가 변경되지 않는 한 같은 함수
  const subscribe = useCallback(() => {
    // ...
  }, [userId]);
  
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);

  // ...
}

사이트맵

모든 문서 페이지 개요

profile
프론트에_가까운_풀스택_개발자

0개의 댓글