useSyncExternalStore

Chaerin Kim·2023년 11월 27일

외부 store를 구독할 수 있는 React Hook

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

Reference

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

컴포넌트의 최상위 레벨에서 useSyncExternalStore를 호출하여 외부 데이터 저장소에서 값을 읽음.

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

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

Store에 있는 데이터의 스냅샷을 반환함. 두 개의 함수를 인수로 전달해야 함:

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

Parameters

  • subscribe: 단일 callback 인수를 받아 store에 구독하는 함수. Store가 변경되면 제공된 callback을 호출해야함. 그러면 컴포넌트가 다시 렌더링됨. subscribe 함수는 구독을 정리하는 함수를 반환해야함.

  • getSnapshot: 컴포넌트에 필요한 store 데이터의 스냅샷을 반환하는 함수. Store가 변경되지 않은 상태에서 getSnapshot을 반복적으로 호출하면 동일한 값을 반환해야함. Store가 변경되고 반환된 값이 다른 경우(Object.is에 의해 비교됨), React는 컴포넌트를 다시 렌더링함.

  • getServerSnapshot (optional): Store에 있는 데이터의 초기 스냅샷을 반환하는 함수. 서버 렌더링 도중과 클라이언트에서 서버 렌더링된 콘텐츠의 hydration 중에만 사용됨. 서버 스냅샷은 클라이언트와 서버 간에 동일해야 하며, 일반적으로 직렬화되어 서버에서 클라이언트로 전달됨. 이 인수를 생략하면 서버에서 컴포넌트를 렌더링할 때 오류가 발생함.

Returns

렌더링 로직에 사용할 수 있는 store의 현재 스냅샷을 반환함.

Caveats

  • getSnapshot이 반환하는 store 스냅샷은 변경 불가능 해야함. 기본 store에 변경 가능한 데이터가 있는 경우, 데이터가 변경되었을 때 변경 불가능한 새 스냅샷을 반환함. 그렇지 않으면 캐시된 마지막 스냅샷을 반환함.

  • 다시 렌더링하는 동안 다른 subscribe 함수가 전달되면 React는 새로 전달된 subscribe 함수를 사용하여 저장소를 다시 구독함. 컴포넌트 외부에서 subscribe을 선언하면 이를 방지할 수 있음.

  • Non-blocking transition update 중에 store가 변경되면 React는 해당 업데이트를 블로킹으로 수행하도록 되돌아감. 구체적으로, 모든 트랜지션 업데이트에 대해 React는 DOM에 변경 사항을 적용하기 직전에 getSnapshot을 한 번 더 호출함. 처음 호출했을 때와 다른 값을 반환하면 React는 업데이트를 처음부터 다시 시작하고 이번에는 블로킹 업데이트로 적용하여 화면의 모든 컴포넌트가 동일한 버전의 store를 반영하도록 함.

  • useSyncExternalStore가 반환한 store 값을 기반으로 렌더링을 일시 중단하는 것은 권장하지 않음. 그 이유는 외부 store에 대한 변형을 non-blocking transition update로 표시할 수 없기 때문에 가장 가까운 Suspense fallback을 트리거하여 화면에 이미 렌더링된 콘텐츠를 로딩 스피너로 대체하게 되고, 이는 일반적으로 좋지 않은 UX이기 때문.

예를 들어, 다음과 같은 코드는 권장되지 않음:

const LazyProductDetailPage = lazy(() => import('./ProductDetailPage.js'));

function ShoppingApp() {
  const selectedProductId = useSyncExternalStore(...);

  // ❌ Calling `use` with a Promise dependent on `selectedProductId`
  const data = use(fetchItem(selectedProductId))

  // ❌ Conditionally rendering a lazy component based on `selectedProductId`
  return selectedProductId != null ? <LazyProductDetailPage /> : <FeaturedProducts />;
}

Usage

Subscribing to an external store

대부분의 React 컴포넌트는 props, state, context에서만 데이터를 읽음. 그러나 때로는 컴포넌트가 시간이 지남에 따라 변경되는 React 외부의 저장소에서 일부 데이터를 읽어야 할 때가 있음. 예를 들면:

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

컴포넌트의 최상위 수준에서 useSyncExternalStore를 호출하여 외부 데이터 저장소에서 값을 읽음:

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

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

이 함수는 스토어에 있는 데이터의 스냅샷을 반환하고, 두 개의 함수를 인수로 받음:

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

React는 이 함수를 사용하여 컴포넌트를 스토어에 구독한 상태로 유지하고 변경 사항이 있을 때 다시 렌더링함.

예를 들어, 아래 에제에서 todosStore는 React 외부에 데이터를 저장하는 외부 store로 구현되어 있음. TodosApp 컴포넌트는 useSyncExternalStore Hook으로 해당 외부 스토어에 연결됨.

// App.js
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>
    </>
  );
}
// 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();
  }
}

Note

가능하면 내장된 React state를 useStateuseReducer와 함께 사용하는 것이 좋음. useSyncExternalStore API는 기존의 비 React 코드와 통합해야 할 때 주로 유용함.

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에서 값을 읽는 방법과 그 변경 사항을 구독하는 방법을 알고 있음. 디바이스에서 네트워크의 연결이 끊어지면 컴포넌트가 response로 다시 렌더링되는 것을 확인할 수 있음:

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);
  };
}

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를 호출할 수 있음:

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

0개의 댓글