[번역] Suspense를 지원하는 라이브러리를 직접 구축하며 Suspense 학습하기

TapK·2024년 7월 24일
38
post-thumbnail

원문: https://www.bbss.dev/posts/react-learn-suspense/

📝 참고

Suspense를 지원하는 데이터 원본만 Suspense 컴포넌트를 활성화합니다. 여기에는 다음이 포함됩니다.

  • RelayNext.js와 같은 Suspense 지원 프레임워크를 사용한 데이터 페칭
  • lazy를 이용한 컴포넌트 코드 지연 로딩
  • use를 이용하는 Promise의 값 읽기
    Suspense는 이펙트 또는 이벤트 핸들러 내부에서 데이터를 가져오는 시점을 감지하지 못합니다.

위의 Albums 컴포넌트에서 데이터를 로드하는 정확한 방법은 프레임워크마다 다릅니다. Suspense 지원 프레임워크를 사용하는 경우 해당 데이터 페칭 문서에서 자세한 내용을 확인할 수 있습니다.

독립적인 프레임워크를 사용하지 않는 한 데이터 페칭을 위한 Suspense 지원은 아직 지원되지 않습니다. Suspense 지원에 대한 데이터 원본을 구현하기 위한 요구 사항은 불안정하고 문서화되어 있지 않습니다. Suspense와 데이터 소스를 통합하기 위한 공식 API는 향후 리액트의 새로운 버전에서 출시될 예정입니다.

공식 문서 자료 제공

Suspense(동시성 렌더링과 함께)는 리액트 v16.6.0 부터 제공되던 기능이었습니다. 하지만 React.lazy와 "Suspense 지원 라이브러리"의 제한된 앱 외에는 실제로 사용되는 것을 자주 보지 못했습니다.

무슨 일이 있던 걸까요?

리액트 v19 릴리스가 임박한 현재, Suspense는 아직 최적기에 사용할 준비가 되지 않았습니다. API와 내부가 아직 불완전해 보입니다. 사실, 리액트 팀이 Suspense API를 불완전하다고 생각하는 것인지 API에 대한 문서가 전혀 존재하지 않습니다. Suspense 문서에서는 Suspense를 사용하는 유일한 방법은 "Suspense 지원 프레임워크"뿐이라고 명시하고 있습니다.

문서에서 API를 의도적으로 숨기는 것은 좋지않다고 생각하지만, 괜찮아요! 그들의 게임에 동참해보죠! Suspense 지원 라이브러리를 만들어서 사용해 봅시다.

그 과정에서 Suspense의 베일을 하나씩 벗겨보겠습니다.

철학

Suspense로 바로 들어가기 전에 간단한 데이터 페칭 컴포넌트를 만들어서 우리가 어디에 있는지 살펴봅시다.

리액트 101 강의에서는 일반적으로 데이터 페칭시 아래와 같은 코드를 작성하도록 가르칩니다.

const Artist = ({ artistName }) => {
  const [artistInfo, setArtistInfo] = useState(null);

  useEffect(() => {
    fetch(`https://api/artists/${artistName}`)
      .then((res) => res.json())
      .then((json) => setArtistInfo(json));
  }, [artistName]);

  return (
    <div>
      <h2>{artistName}</h2>
      <p>{artistInfo.bio}</p>
    </div>
  );
};

이 강좌의 다음 내용은 이 코드가 실제로는 좋은 코드가 아니라는 내용입니다. 다음과같이 많은 부분이 누락되어 있기 때문입니다.

  • 에러 상태 처리
  • 대기 상태 처리
  • 경쟁 조건 처리
  • 공유 캐싱(이 부분은 나중에 다룰 예정입니다.)

구현이 완료되면 컴포넌트는 다음과 같이 보입니다.

const Artist = ({ artistName }) => {
  const [artistInfo, setArtistInfo] = useState(null);
  const [error, setError] = useState(null);
  const [isPending, setIsPending] = useState(false);

  useEffect(() => {
    let stale = false;
    setIsPending(true);
    setError(null);

    fetch(`https://api/artists/${artistName}`)
      .then((res) => res.json())
      .then((json) => {
        if (!stale) {
          setIsPending(false);
          setArtistInfo(json);
        }
      })
      .catch((err) => setError(err));

    // 기본적으로 AbortController가 수행하는 작업은 다음과 같습니다.
    return () => {
      stale = true;
    };
  }, [artistName]);

  if (isPending) return <SpinnerFallback />;
  if (error !== null) return <ErrorFallback />;

  return (
    <div>
      <h2>{artistName}</h2>
      <p>{artistInfo?.bio}</p>
    </div>
  );
};

이 시점에서 강사는 보통 이 방식도 좋지만, 데이터를 가져올 때마다 이 코드를 반복하게 될 것입니다 라고 말하면서 여러분의 첫 번째 훅을 작성하는 방법을 알려줄 것입니다.

교육적인 필요성은 존중하지만 실제로는 그렇게 접근하는 것이 가장 좋은 방법은 아닙니다. 보류 및 에러 상태를 추적하는 것의 문제점은 훅에 이 로직을 묻어두더라도 여전히 컴포넌트 수준에서 해당 상태들을 처리해야 한다는 것입니다.

const Artist = ({ artistName }) => {
    const [artistInfo, isPending, error] = useFetch(`https://api/artists/${artistName}`)

    // 이 작업은 많지는 않지만 매번 이 작업을 수행해야 합니다.
    if (isPending) return <SpinnerFallback />
    if (error !== null) return <ErrorFallback />

    return (
        <div>
            <h2>{artistName}</h2>
            <p>{artistInfo.bio}</p>
            <Album albumName={artistInfo.lastAlbumName}>
        </div>
    )
}

const Album = ({ albumName }) => {
     const [albumInfo, isPending, error] = useFetch(`https://api/artists/${albumName}`)

    // 이 작업은 많지는 않지만 매번 이 작업을 수행해야 합니다.
    if (isPending) return <SpinnerFallback />
    if (error !== null) return <ErrorFallback />

    return ...
}

useFetchSpinnerFallbackErrorFallback 반환을 처리할 수 있다고 상상해 보세요.

ErrorFallback의 경우 에러 바운더리가 있습니다.

const Artist = ({ artistName }) => {
    const [artistInfo, isPending, error] = useFetch(`https://api/artists/${artistName}`)

    if (isPending) return <SpinnerFallback />
    // 가장 가까운 에러 바운더리에서 핸들링합니다. (useFetch로 이동할 수 있음)
    if (error !== null) return throw error

    return (
        <div>
            <h2>{artistName}</h2>
            <p>{artistInfo.bio}</p>
            <Album albumName={artistInfo.lastAlbumName}>
        </div>
    )
}

const App = () => {
    return (
        <ErrorBoundary fallback={<ErrorFallback />}>
            <Artist artistName="Julian Casablanca" />
        </ErrorBoundary>
    )
}

SpinnerFallback의 경우, 사실 이것이 바로 Suspense의 목적입니다. 혼란을 피하려고 fallback 메커니즘을 트리거하는 구현은 생략하겠지만(나중에 다시 다룰 테니 걱정하지 마세요!), 컴포넌트 관점에서 보면 다음과 같습니다.

const Artist = ({ artistName }) => {
    // 와우! 이제 훅이 에러와 로딩 상태를 모두 내부에서 처리합니다!
    const [artistInfo] = useFetch(`https://api/artists/${artistName}`)

    return (
        <div>
            <h2>{artistName}</h2>
            <p>{artistInfo.bio}</p>
            <Album albumName={artistInfo.lastAlbumName}>
        </div>
    )
}

const App = () => {
    return (
        <ErrorBoundary fallback={<ErrorFallback />}>
            <Suspense fallback={<SpinnerFallback />}>
                <Artist artistName="Julian Casablanca" />
            </Supense>
        </ErrorBoundary>
    )
}

이 시점에서는 useFetch의 구현 코드를 고민하지 마세요. 사용하려면 아직 갈 길이 멀기 때문입니다.

주의: 바운더리 재설정 상태

보류 및 에러 상태를 처리하기 위해 바운더리를 사용할 때의 부작용은 바운더리에 도달하면 모든 자식이 fallback을 위해 버려진다는 것입니다. 그 결과 자식 컴포넌트의 상태가 손실됩니다.

const Counter = () => {
  // 에러 바운더리에 도달하면 모든 상태가 초기화됩니다.
  const [count, setCount] = useState(0);
  const [error, setError] = useState<null | Error>(null);

  // 에러 바운더리 트리거
  if (error) throw error;

  const increment = () => setCount((n) => n + 1);
  return (
    <div>
      <p>Counter: {count}</p>
      <button onClick={increment}>Increment</button>
      <button
        onClick={() => {
          // 에러 바운더리를 트리거하려면,
          // 에러는 반드시, 렌더링 주기 중에 발생해야 합니다.
          // 이벤트 핸들러나 이펙트에서 에러를 던지면
          // 에러 바운더리를 트리거하지 않습니다!
          setError(new Error("Whoops something went wrong!"));
        }}
      >
        Throw Error
      </button>
    </div>
  );
};

시도해 보세요.

이 방법은 괜찮은 방법입니다. 예를 들어 특정 컴포넌트 내에 에러가 발생한 경우 이를 제거하는 가장 확실한 방법은 완전히 재설정하는 것입니다.


: 바운더리를 전략적으로 배치하면 애플리케이션의 어떤 부분이 함께 재설정되는지 제어할 수 있습니다.

루트만 래핑할 수 있는 것이 아니라 모든 컴포넌트를 래핑할 필요도 없다는 점을 기억하세요.

이는 에러 바운더리와 Suspense 바운더리 모두에 해당됩니다.


보류 중인 상태를 추적하는 측면에서 이는 어려운 문제입니다. 초기 렌더링에서 상태는 보류 중입니다. 그러면 Suspense 바운더리가 트리거됩니다. Suspense 바운더리가 재설정되면 컴포넌트가 다시 마운트되어 새 요청을 전송하고 바운더리를 다시 트리거합니다. 따라서 이러한 패러다임에서는 동일한 요청에 대한 지속적인 참조가 없으면 데이터를 성공적으로 표시할 수 없습니다.

따라서 컴포넌트의 생명주기보다 오래 지속되는 캐시가 필요합니다.

공유 캐시 구축

공유 캐시는 중요합니다. 동기화되지 않은 데이터, 너무 많은 요청을 보내는 것을 방지하고, 고급 데이터 관리 기능을 구현하는 데 필수적입니다. 우리의 경우, 컴포넌트가 자신의 요청 상태를 관리하지 않도록 하여 더욱 단순하게 만드는 것도 중요합니다. 그렇지 않으면 컴포넌트는 요청 상태를 잃어버리기 쉽습니다.

캐싱은 제대로 수행하기 어렵습니다. 사실, 데이터 페칭 라이브러리 대부분이 캐싱 라이브러리일 정도로 이는 매우 어렵습니다.

캐시를 의도적으로 단순하게 유지함으로써 "캐시 작성 방법"이라는 토끼 굴에 빠지는 것을 피할 수 있습니다. API를 사용하면 특정 URL을 요청할 수 있으며 이전과 마찬가지로 데이터, 보류 중 및 에러에 대한 값을 반환합니다. 유일한 차이점은 요청 생명 주기가 요청하는 컴포넌트가 아닌 캐시 내에서 관리된다는 것입니다.

그런 다음 캐시를 실제 Suspense 지원 라이브러리로 전환합니다.

FetchCache 클래스

캐시가 갖는 정확한 API와 기능에 대해 창의적으로 접근할 수 있습니다. 다음은 간단한 구현 코드입니다.

class FetchCache {
  // 모든 요청을 위한 컨테이너
  requestMap = new Map();

  // 상태 업데이트의 브로드캐스팅을 위한 콜백 추적
  subscribers = new Set();

  fetchUrl(url, refetch) {
    const currentData = this.requestMap.get(url);

    if (currentData) {
      // 이 요청은 이미 진행 중입니다.
      if (currentData.status === "pending") return currentData;
      // 데이터가 이미 캐시에 있고 명시적으로 다시 요청되지 않은 경우 currentData를 반환함
      // 상태는 완료(fulfilled)되었거나 거부(rejected)된 것으로 처리
      if (!refetch) return currentData;
    }

    // 경쟁 요청을 피하려고 상태를 보류 중으로 설정
    const pendingState = { status: "pending" };
    this.requestMap.set(url, pendingState);

    const broadcastUpdate = () => {
      // 실행하지 않기 위해 알림 지연
      // 렌더링 주기 중
      // https://reactjs.org/link/setstate-in-render
      setTimeout(() => {
        for (const callback of this.subscribers) {
          callback();
        }
      }, 0);
    };

    // 요청을 발송하고 관찰하기
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        // 성공 상태를 캐시에 저장
        this.requestMap.set(url, { status: "fulfilled", value: data });
      })
      .catch((error) => {
        // 에러 상태를 캐시에 저장
        this.requestMap.set(url, { status: "rejected", reason: error });
      })
      .finally(() => {
        // 어떤 일이 발생시 구독자에게 알림
        // 해당 상태가 갱신해야 함을 알림
        broadcastUpdate();
      });

    // 요청이 현재 보류 중임을 보고합니다.
    broadcastUpdate();

    // 요청이 현재 보류 중임을 보고합니다.
    return pendingState;
  }

  subscribe(callback) {
    this.subscribers.add(callback);
    return () => this.subscribers.delete(callback);
  }
}

FetchCache에는 두 가지 메서드가 있습니다.

  • fetchUrl(url, refetch)

    URL을 요청합니다.

    캐시에 있는 현재 상태를 반환합니다.

  • subscribe(callback)

    캐시에서 새 데이터를 사용할 수 있을 때 알림을 위한 콜백을 등록합니다.

    구독을 취소하는 함수를 반환합니다.

FetchCache Provider

이제 캐시가 생겼으니 이를 리액트 트리에 노출할 수단인 Context Provider가 필요합니다.

const fetchCacheContext = createContext(null);

const FetchCacheProvider = ({ children, fetchCache }) => {
  // 이 상태 훅은 리렌더를 트리거할 때만 사용됩니다.
  const [, setEmptyState] = useState({});
  const rerender = useCallback(() => setEmptyState({}), []);

  // 구독자를 fetchCache에 등록하는 이펙트
  useEffect(() => {
    const unsubscribe = fetchCache.subscribe(() => rerender());
    return unsubscribe;
  }, [fetchCache, rerender]);

  return (
    <fetchCacheContext.Provider
      value={{
        // 깜짝 질문: 여기서 'bind'가 필요한 이유는 무엇인가요?
        fetchUrl: fetchCache.fetchUrl.bind(fetchCache),
      }}
    >
      {children}
    </fetchCacheContext.Provider>
  );
};

useFetch 훅

마지막으로 FetchCacheProvider를 활용하는 useFetch 훅을 작성해 보겠습니다.

const useFetch = (url) => {
  const { fetchUrl } = useContext(fetchCacheContext);
  const state = fetchUrl(url);
  const isPending = state.status === "pending";
  const error = state.reason;
  const data = state.value;

  // 데이터 새로 고침 허용
  const reload = () => fetchUrl(url, true);

  return [data, isPending, error, reload];
};

코드 실행!


이 데모에서는 아티스트와 앨범 대신 사용자와 게시물을 사용합니다. 제공: https://jsonplaceholder.typicode.com


새로 고침 버튼을 클릭하고 어떤 일이 발생하는지 확인하세요. URL을 끊고 요청이 의도적으로 실패하도록 만들어 보세요.

계속 진행하기 전에 코드 베이스를 자유롭게 탐색하고 무슨 일이 일어나는지를 잘 이해했는지 확인하세요. 이해가 되지 않는 부분이 있으면 이전 섹션을 다시 읽어보세요.

Promise로 데이터 추적하기

지금까지 우리는 Promise에 대해 크게 생각하지 않고 일관되게 사용해 왔습니다. Promise는 3가지 상태가 있다는 것을 기억하세요.

  • 보류 중
  • 값으로 완료됨(undefined 또는 null일 수도 있습니다.)
  • 특정 이유로 거부됨(일반적으론 Error지만 반드시 그렇지는 않습니다.)

원칙적으로 FetchCache처럼 이 정보를 Promise와 별도로 추적할 필요는 없습니다. 특수 함수를 사용해 정보를 읽는 동안 Promise 자체만 추적하면 됩니다.

여기서 문제는 Promise는 비동기 데이터 액세스만 허용한다는 것입니다.(이미 해결이 되었더라도요.)

// 해결된 Promise 생성
const promise = Promise.resolve();
console.log("1");
promise.then(() => {
  console.log("3");
});
console.log("2");

// 결과물
1;
2;
3;

데이터를 추출하려면 상태를 동기적으로 읽을 방법이 필요합니다. Promise 객체에 프로퍼티를 추가하여 상태를 추적하면 됩니다.

function readPromiseState(promise) {
  switch (promise.status) {
    case "pending":
      return { status: "pending" };
    case "fulfilled":
      return { status: "fulfilled", value: promise.value };
    case "rejected":
      return { status: "rejected", reason: promise.reason };
    default:
      promise.status = "pending";
      promise.then((value) => {
        promise.status = "fulfilled";
        promise.value = value;
      });
      promise.catch((reason) => {
        promise.status = "rejected";
        promise.reason = reason;
      });
      return readPromiseState(promise);
  }
}

Promise로 readPromiseState를 처음 호출하면 보류 중으로 반환됩니다. 하지만 일단 안정화되면 우리가 액세스할 수 있는 방식으로 자체 상태를 업데이트합니다.

원하는 REPL에서 시도해 보세요.

> const promise1 = Promise.resolve("Hello World!")
> readPromiseState(promise1)
{ status: 'pending' }
> readPromiseState(promise1)
{ status: 'fulfilled', value: 'Hello World!' }

> const promise2 = Promise.reject(new Error("Whoops!"))
> readPromiseState(promise2)
{ status: 'pending' }
> readPromiseState(promise2)
{ status: 'rejected', reason: [Error: Whoops!] }

> const promise3 = new Promise(res => setTimeout(res, 5000))
> readPromiseState(promise3)
{ status: 'pending' }
> readPromiseState(promise3)
{ status: 'pending' }
> readPromiseState(promise3)
{ status: 'pending' }
> readPromiseState(promise3)
{ status: 'fulfilled', value: undefined }

스포일러: 이것이 잘못됐다고 생각할 수도 있지만, 이것이 바로 리액트 v19 use의 내부 동작 방식입니다.


캐시에서 Promise 사용

이 섹션에서는 상태 객체 대신 readPromiseState를 사용해 Promise를 추적하도록 FetchCache를 가볍게 수정해 보겠습니다. 변경 사항은 상당히 간단합니다.

class FetchCache {
  // 모든 요청을 위한 컨테이너
  requestMap = new Map();

  // 상태 업데이트의 브로드캐스팅을 위한 콜백 추적
  subscribers = new Set();

  fetchUrl(url, refetch) {
    const currentData = this.requestMap.get(url);

    if (currentData) {
      // 이 요청은 이미 진행 중입니다.
      if (readPromiseState(currentData).status === "pending")
        return readPromiseState(currentData);
      // 데이터가 이미 캐시에 있고 명시적으로 다시 요청되지 않은 경우
      // 명시적으로 재요청되지 않은 경우 반환.
      // 상태가 완료되었거나 거부된 경우.
      if (!refetch) return readPromiseState(currentData);
    }

    // 경쟁 요청을 피하려고 상태를 보류 중으로 설정
    const pendingState = { status: "pending" };
    this.requestMap.set(url, pendingState);

    const broadcastUpdate = () => {
      // 실행하지 않기 위해 알림 지연
      // 렌더링 주기 중
      // https://reactjs.org/link/setstate-in-render
      setTimeout(() => {
        for (const callback of this.subscribers) {
          callback();
        }
      }, 0);
    };

    // 요청을 dispatch하고 관찰하기
    const newPromise = fetch(url).then((res) => res.json());

    newPromise.finally(() => {
      // 어떤 일이 발생하던 구독자에게 알립니다.
      // 해당 상태를 새로 고쳐야 합니다.
      broadcastUpdate();
    });

    this.requestMap.set(url, newPromise);

    // 요청이 현재 보류 중임을 보고합니다.
    broadcastUpdate();

    // 요청이 현재 보류 중임을 보고합니다.
    return readPromiseState(newPromise);
  }

  subscribe(callback) {
    this.subscribers.add(callback);
    return () => this.subscribers.delete(callback);
  }
}

이전 코드 샌드박스의 포크에 붙여 넣으면 다른 변경이 필요하지 않다는 것을 확인할 수 있습니다. 이것은 우리가 필요한 모든 데이터가 Promise 자체에 있다는 것을 보여줍니다.

이제 한 가지 더 변경하여 FetchCache에서 readPromiseState를 사용하는 대신 useFetch 훅으로 이동하겠습니다.

class FetchCache {
  // 모든 요청을 위한 컨테이너
  requestMap = new Map();

  // 상태 업데이트의 브로드캐스팅을 위한 콜백 추적
  subscribers = new Set();

  fetchUrl(url, refetch) {
    const currentData = this.requestMap.get(url);

    if (currentData) {
      // 이 요청은 이미 진행 중입니다.
      // `readPromiseState` 확인을 유지해야 합니다.
      if (readPromiseState(currentData.status) === "pending")
        return currentData;
      // 데이터가 이미 캐시에 있고 명시적으로 다시 요청되지 않은 경우
      // 명시적으로 재요청되지 않은 경우 반환.
      // 상태가 완료되었거나 거부된 경우.
      if (!refetch) return currentData;
    }

    // 경쟁 요청을 피하려고 상태를 보류 중으로 설정
    const pendingState = { status: "pending" };
    this.requestMap.set(url, pendingState);

    const broadcastUpdate = () => {
      // 실행하지 않기 위해 알림 지연
      // 렌더링 주기 중
      // https://reactjs.org/link/setstate-in-render
      setTimeout(() => {
        for (const callback of this.subscribers) {
          callback();
        }
      }, 0);
    };

    // 요청을 dispatch하고 관찰하기
    const newPromise = fetch(url).then((res) => res.json());

    newPromise.finally(() => {
      // 어떤 일이 발생하던 구독자에게 알립니다.
      // 해당 상태를 새로 고쳐야 합니다.
      broadcastUpdate();
    });

    this.requestMap.set(url, newPromise);

    // 요청이 현재 보류 중임을 보고합니다.
    broadcastUpdate();

    // 요청이 현재 보류 중임을 보고합니다.
    return newPromise;
  }

  subscribe(callback) {
    this.subscribers.add(callback);
    return () => this.subscribers.delete(callback);
  }
}
const useFetch = (url) => {
  const { fetchUrl } = useContext(fetchCacheContext);
  const state = readPromiseState(fetchUrl(url));
  const isPending = state.status === "pending";
  const error = state.reason;
  const data = state.value;

  // 데이터 새로 고침 허용
  const reload = () => fetchUrl(url, true);

  return [data, isPending, error, reload];
};

Suspense 활성화 🎉

마지막으로 변경할 사항은 useFetch를 Suspense 훅으로 바꾸는 것입니다.

Error를 던져 에러 바운더리를 트리거하는 방법을 기억하시나요? Suspense 바운더리를 트리거하는 것은 Promise를 던진다는 점을 제외하면 동일합니다.

에러 바운더리를 벗어나려면 에러 바운더리를 재설정하도록 호출하는 코드가 있어야 합니다. 반면에 Suspense 바운더리는 Promise가 해결되면(또는 거부된 경우 에러 바운더리를 트리거하면) 자동으로 재설정됩니다.

Suspense를 useFetch에 활성화하려면 두 가지 변경이 필요합니다.

첫째, 보류 중인 경우 Promise를 던져야 합니다.

둘째, Promise 거부된 경우 그 이유(에러)를 반환해야 합니다.

Promise 완료되면 데이터를 반환하면 됩니다. 소비하는 컴포넌트는 더 이상 에러나 보류 상태를 고려할 필요가 없습니다.

const useFetch = (url) => {
  const { fetchUrl } = useContext(fetchCacheContext);
  const promise = fetchUrl(url);
  const state = readPromiseState(promise);

  // 보류 중인 Promise throw
  const isPending = state.status === "pending";
  if (isPending) throw promise;

  // 거부된 이유 throw
  const error = state.reason;
  if (error) throw error;

  const data = state.value;

  // 데이터 새로 고침 허용
  const reload = () => fetchUrl(url, true);

  // 현재 데이터만 반환
  return [data, reload];
};

다음으로, useFetch를 사용하여 데이터를 직접 사용하는 컴포넌트를 업데이트합니다...

export const User = ({ userId }) => {
 const [data, reload] = useFetch(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  );
// ...

...그리고 애플리케이션을 Suspense 및 에러 바운더리로 감쌉니다.

function App() {
  return (
    <div className="App">
      <FetchCacheProvider fetchCache={fetchCache}>
        <ErrorBoundary fallback={<ErrorFallback />}>
          <Suspense fallback={<Spinner />}>
            <h1>My App</h1>
            <User userId={1} />
          </Suspense>
        </ErrorBoundary>
      </FetchCacheProvider>
    </div>
  );
}

여기서 모범 사례는 항상 애플리케이션 루트를 ErrorBoundarySuspense 둘 다 감싸는 것입니다. 둘 중 하나에 포함된 모든 것은 바운더리가 트리거될 때 로컬 상태가 초기화된다는 점을 명심하세요.

코드 샌드박스에서 사용해 보세요.


독자를 위한 연습 문제입니다: 이번에는 새로 고침 버튼을 클릭하면 전체 앱이 로딩 상태로 바뀝니다. 왜 이런 현상이 발생하며 어떻게 이전 동작으로 복원할 수 있을까요?

힌트: 에러나 Promise를 던지면 가장 가까운 바운더리가 트리거됩니다.


이런. 우린 use()를 재발명했습니다!

리액트 v19에는 새로운 훅인 use가 도입되었습니다. 이 함수는 컨텍스트(useContext와 유사) 또는 Promise에서 데이터를 사용할 수 있습니다. useFetchuse를 도입하면 어떤 모습인지 살펴보겠습니다.

const useFetch = (url) => {
  const { fetchUrl } = useContext(fetchCacheContext);
  const promise = fetchUrl(url);

  //  보류 및 거부된 promise에 대한 throw 처리
  const data = use(promise);

  // 데이터 새로 고침 허용
  const reload = () => fetchUrl(url, true);

  // 현재 데이터만 반환
  return [data, reload];
};

깔끔하네요!

리뷰

  • Suspense가 짧은 컴포넌트 함수 작성에 도움이 되는 방법
  • 바운더리가 트리거 될 때 상태를 유지하는 방법
  • Promise에서 "동기적으로" 읽는 방법
  • "Suspense 지원" 훅을 작성하는 방법
  • use() 훅이 Promise에서 하는 일

끝맺음

글을 다 읽으신 것을 축하드립니다! 이 글을 잘 따라가셨다면 이제 자신만의 Suspense 지원 훅을 만드는 데 필요한 지식을 갖추셨을 것입니다. 더 중요한 것은 이제 문제가 발생했을 때 Suspense를 디버깅하는 데 자신감을 느끼게 되었을 것입니다.

이 글에 소개된 구현 중 일부는 어색해 보일 수 있지만, 모든 것이 실제 Suspense 지원 라이브러리의 작동 방식을 대체로 나타냅니다.

예를 들어 TanStack Query를 보면 매핑이 매우 명확합니다.

  • useQuery -> useFetch
  • QueryClient -> FetchCache
  • QueryClientProvider -> FetchCacheProvider

실질적인 차이점은 제공되는 기능에 있습니다.

FetchCacheProvider 구현

FetchCache의 전역 인스턴스를 사용하여 페칭 상태를 유지했지만, Suspense 및 에러 바운더리 밖에 있다면 컨텍스트 내에서 로컬로 초기화된 인스턴스를 사용할 수도 있습니다. 그렇지 않으면 fetch 상태가 재설정됩니다.

const FetchCacheProvider = ({ children, fetchCache }) => {
  const [fetchCache] = useState(() => new FetchCache());
  // 이 상태 훅은 리렌더를 트리거할 때만 사용됩니다.
  const [, setEmptyState] = useState({});
  const rerender = useCallback(() => setEmptyState({}), []);

  // 구독자를 fetchCache에 등록하는 이펙트
  useEffect(() => {
    const unsubscribe = fetchCache.subscribe(() => rerender());
    return unsubscribe;
  }, [fetchCache, rerender]);

  return (
    <fetchCacheContext.Provider
      value={{
        // 팝 퀴즈: 여기서 'bind'가 필요한 이유는 무엇인가요?
        fetchUrl: fetchCache.fetchUrl.bind(fetchCache),
      }}
    >
      {children}
    </fetchCacheContext.Provider>
  );
};

이러한 Provider의 구현도 잘 작동했을 것입니다. 하지만 이건 좋지 않은 습관입니다.

이런 식으로 FetchCacheProvider를 작성하면 특정 FetchCache 구현과 긴밀하게 결합합니다. 결과적으로 FetchCacheProvider는 다른 패칭 메커니즘(Axios, GraphQL, 단위 테스트용 mocks 등)을 사용하는 대체 구현을 허용할 수 없게 됩니다.

Suspense에서 놓친 것은 무엇인가요?

렌더링 수준 캐싱은 이전에 리액트 팀이 제공하겠다고 암시했던 기능입니다. 이 아이디어는 상태 재설정 후에도 살아남을 수 있는 일부 내부 리액트 컨텍스트 내에서 (Promise와 같은) 데이터를 캐시 할 수 있다는 것입니다.

이렇게 하면 컨텍스트나 전역 캐시가 필요 없는 "로컬" 상태를 기반으로 Suspense를 구현할 수 있습니다. 구현 측면에서는 아직 진행 중이므로 더 이상 언급할 것이 많지 않습니다.

그 점을 제외하면 Suspense API는 꽤 완성도가 높아 보입니다. 문서화하는 것을 싫어하는 리액트 팀을 이해할 수 없습니다.

Transitions

이 글에서 자세히 설명하지는 않겠지만, Suspense와 Transitions의 상호작용에 관한 문서는 꽤 훌륭합니다. Suspense를 기반으로 구축하는 경우 해당 페이지를 반드시 읽어보시기를 바랍니다.

특히 곧 출시될 v19의 변경 사항으로 인해 useTransition에는 Suspense보다 훨씬 더 많은 것이 있습니다. 공식 v19 릴리스 후 향후 포스팅에서 이 훅에 대해 자세히 다뤄보도록 하겠습니다.

뉴스레터를 구독하고 useTransition에 대한 전체 가이드를 가장 먼저 받아보세요.

profile
누구나 읽기 편한 글을 위해

0개의 댓글