본문 바로가기

카테고리 없음

[번역] useSyncExternalStore

 

 

useSyncExternalStore – React

The library for web and native user interfaces

react.dev

 

useSyncExternalStore는 외부 스토어를 구독할 수 있는 리액트 훅이다.

 

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

 

이 함수는 스토어에 있는 데이터의 스냅샷을 반환한다. 두 개의 

 

1. subscribe 함수는 스토어를 구독하고 구독을 취소하는 함수를 반환해야한다. 

2. getSnapshot 함수는 스토어에서 데이터의 스냅샷을 읽어야한다. 

 

- subscribe: 단일 콜백 인자를 받아서 스토어를 구독하는 함수이다. 스토어가 변경되면 제공된 콜백을 호출한다. 이는 컴포넌트의 리렌더를 유발한다. 구독함수는 구독을 정리할 수 있는 clean up function을 반환해야한다 .

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

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

 

반환값은 렌더링 로직에 사용할 수 있는 스토어의 스냅샷이다. 

 

- getSnapshot이 반환하는 스토어 스냅샷은 불변이어야 한다. 기본 스토어에 변경가능한 데이터가 있는 경우 데이터가 변경된 경우 변경 불가능한 새 스냅샷을 반환한다. 그렇지 않으면 캐시된 마지막 스냅샷을 반환한다. 

- 다시 렌더링하는 동안 subscribe함수가 전달되면 리액트는 새로 전달된 구독함수를 사용하여 스토어를 다시 구독한다. 컴포넌트 외부에 구독을 선언하면 이를 방지할 수 있다. 

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

- useSyncExternalStore가 반환한 스토어 값을 기반으로 렌더링을 일시 중단하는 것은 권장하지 않는다. 그 이유는 외부 스토어에 대한 변경 사항은 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 />;
}

 

대부분의 React 컴포넌트는 props, state, context에서만 데이터를 읽는다. 그러나 컴포넌트가 시간이 지남에 따라 변경되는 React 외부의 일부 저장소에서 일부 데이터를 읽어야하는 경우가 있다. 여기에는 다음이 포함된다. 

 

- 리액트 외부에 상태를 보관하는 서드파티 상태관리 라이브러리

- 변경 가능한 값과 그 변경 사항을 구독하는 이벤트를 노출하는 브라우저 API 

 

컴포넌트 최상위 레벨에서 useSyncExternalStore를 호출하여 외부 데이터 스토어에서 값을 읽는다. 

 

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

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

 

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

 

1. subscribe함수는 스토어를 구독하고 구독을 취소하는 함수를 반환해야한다. 

2. getSnapshot 함수는 스토어에서 데이터의 스냅샷을 읽어야한다. 

 

리액트는 이 함수를 사용하여 컴포넌트를 스토어에 구독한 상태로 유지하고 변경시 다시 렌더링한다. 예를 들어, 아래 샌드박스에서 todoStore는 리액트 외부에 데이터를 저장하는 외부 store로 구현되어있다. TodoApp 컴포넌트는 useSyncExternalStore로 외부 스토어에 연결한다. 

 

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

 

가능하면 내장된 React State를 useState 및 useReducer와 함께 사용하는 것이 좋다. useSyncExternalStore는 non-React 코드와 통합해야할 때 주로 사용한다. 

 

Subscribing to a browser API 

useSyncExternalStore를 추가해야하는 또 다른 이유는 시간이 지남에 따라 변경되는 브라우저에 노출되는 값을 구독하려는 경우이다. 예를 들어 컴포넌트에 네트워크 연결이 활성 상태인지 여부를 표시하고 싶다고 가정해보자. 브라우저는 navigator.onLine이라는 속성을 통해 이정보를 노출한다. 

 

이 값은 리액트가 모르는 사이에 변경될 수 있으므로 useSyncExternalStore로 읽어야 한다. 

 

import { useSyncExternalStore } from 'react';

function ChatIndicator() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  // ...
}

 

 

getSnapshot 함수를 구현하려면 브라우저 API에서 현재 값을 읽습니다. 

 

function getSnapshot() {
  return navigator.onLine;
}

 

다음으로 구독 함수를 구현해야한다. 예를 들어 navigator.onLine이 변경되면 브라우저는 window에서 온라인 및 오프라인 이벤트를 실행한다. 콜백함수를 해당 이벤트에 구독한 다음 구독을 clean up하는 함수를 반환해야한다. 

 

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

 

이제 React는 외부 navigator.onLine API에서 값을 읽는 방법과 그 변경사항을 구독하는 방법을 알고 있습니다. 네트워크에서 연결이끊기면 컴포넌트가 리렌더되는 것을 확인할 수 있습니다. 

 

Extracting the login to a custom Hook

 

 일반적으로 컴포넌트에서 직접 useSyncExternalStore를 작성하지 않는다. 대신 일반적으로 customHook에서 호출한다. 이렇게 하면 서로 다른 컴포턴트에서 동일한 외부 저장소를 사용할 수 있다. 

 

예를 들어 useOnlineStatusHook은 네트워크가 온라인상태인지 여부를 추적한다:

 

import { useSyncExternalStore } from 'react';

export function useOnlineStatus() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  return isOnline;
}

function getSnapshot() {
  // ...
}

function subscribe(callback) {
  // ...
}

 

 

이제 다른 컴포넌트에서 기본 구현을 반복하지 않고도 useOnlineStatus를 호출할 수 있다. 

 

Adding support for server rendering

리액트 앱이 서버 렌더링을 사용할 경우, 리액트 컴포넌트는 브라우저 환경 외부에서도 실행되어 초기 HTML을 생성한다. 이로 인해 외부 스토어에 연결할 때 몇가지 문제가 발생한다. 

 

- 브라우저 전용 API에 연결하는 경우 서버에 해당 API가 존재하지 않으므로 작동하지 않는다. 

- third party 저장소에 연결하는 경우 서버와 클라이언트 간에 일치하는 데이터가 필요하다. 

 

이러한 문제를 해결하려면 getServerSnapshot 함수를 useSyncExternalStore의 세번째 인수로 전달한다.

 

import { useSyncExternalStore } from 'react';

export function useOnlineStatus() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
  return isOnline;
}

function getSnapshot() {
  return navigator.onLine;
}

function getServerSnapshot() {
  return true; // Always show "Online" for server-generated HTML
}

function subscribe(callback) {
  // ...
}

 

getServerSnapshot 함수는 getSnapshot과 유사하지만 두가지 상황에서만 실행된다. 

 

- HTML을 생성할 때 서버에서 실행된다. 

- 하이드레이션 중, 즉 리액트가 서버 HTML을 가져와서 인터랙티브하게 만들 때 클라이언트에서 실행된다. 

 

이를 통해 앱이 인터랙티브하게 되기 전에 사용할 초기 스냅샷 값을 제공할 수 있다. 서버렌더링에 의미 있는 초기 값이 없는 경우 이 인수를 생략하면 클라이언트 렌더링이 강제로 실행된다. 

 

getServerSnapshot이 초기 클라이언트 렌더링에서 서버에서 반환한 것과 동일한 데이터를 반환하는지 확인한다. 예를 들어 getServerSnapshot이 서버에 미리 채워진 스토어 컨텐츠를 반환한 경우 이 콘텐츠를 클라이언트로 전송해야 한다. 이 작업을 수행하는 한 가지 방법은 서버 렌더링 중에 window.MY_STORE_DATA와 같은 글로벌 설정하는 <script> 태그를 내보내고 클라이언트에서 getServerSnapshot에서 해당글로벌값을 읽는 것이다. 외부 스토어에서 이를 수행하는 방법에 대한 지침을 제공해야한다. 

 

I'm getting an error: "The result of getSnapshot should be cached"

이 오류는 getShapshot 함수가 호출될 때마다 새 객체를 반환한다는 의미이다. 

 

function getSnapshot() {
  // 🔴 Do not return always different objects from getSnapshot
  return {
    todos: myStore.todos
  };
}

 

리액트는 getSnapshot 반환값이 지난번과 다른 경우 컴포넌트를 다시 렌더링한다. 그렇기 때문에 항상 다른 값을 반환하면 무한 루프에 들어가서 이 오류가 발생한다. 

 

실제로 변경된 사항이 있는 경우에만 getSnapshot 객체가 다른 객체를 반환해야 한다. 스토어에 불변 데이터가 포함된 경우 데이터를 직접 반환할 수 있다. 

 

function getSnapshot() {
  // ✅ You can return immutable data
  return myStore.todos;
}

 

만약 당신의 스토어가 mutable하다면 getSnapshot 함수는 변경 불가능한 스냅샷을 반환해야 한다. 즉, 새 객체를 생성해야 하지만 모든 호출에 대해 이 작업을 수행해서는 안 된다. 대신 마지막으로 계산된 스냅샷을 저장하고 저장소의 데이터가 변경되지 않는 경우 지난번과 동일한 스냅샷을 반환해야 한다. 변경 가능한 데이터가 변경되었는지 확인하는 방법은 변경 가능한 저장소에 따라 다르다. 

 

My subscribe function gets called after every re-render

This subscribe function is defined inside a component so it is different on every re-render: 

 

function ChatIndicator() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  
  // 🚩 Always a different function, so React will resubscribe on every re-render
  function subscribe() {
    // ...
  }

  // ...
}

 

 

리렌더링 사이에 다른 subscribe 함수를 전달하면 React가 스토어를 다시 구독한다. 이로 인해 성능 문제가 발생하여 재구독을 피하고 싶다면 구독 함수를 외부로 이동시켜야한다.

 

function ChatIndicator() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  // ...
}

// ✅ Always the same function, so React won't need to resubscribe
function subscribe() {
  // ...
}

 

또는 일부 인자가 변경될 때만 다시 구독을 하도록 useCallback으로 래핑할 수도 있다. 

 

function ChatIndicator({ userId }) {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  
  // ✅ Same function as long as userId doesn't change
  const subscribe = useCallback(() => {
    // ...
  }, [userId]);

  // ...
}