Suspense 설명회

Develop
2024-02-05

Suspense

react 공식문서 Suspense 사용 예시

<Suspense fallback={<Loading />}>
  <SomeComponent />
</Suspense>

Suspense는 무슨 역할인가요?

Suspense는 자식 요소가 로드되기 전까지 로딩(fallback) UI를 보여줍니다. lazy loading, data fetching이 해당됩니다.


Suspense는 어떻게 작동할까요?


Suspense는 throw된 Promise를 포착합니다.

즉, 일반적인 axios 함수를 호출하거나 useQuery를 호출하면 Suspense는 동작하지 않습니다. Promise를 throw하는 과정이 필요합니다.

아래는 리액트 팀에서 공개한 wrapPromise라는 함수입니다.

function wrapPromise(promise) {
  let status = "pending";
  let response;
 
  const suspender = promise.then(
    (res) => {
      status = "success";
      response = res;
    },
    (err) => {
      status = "error";
      response = err;
    }
  );
 
  const read = () => {
    switch (status) {
      case "pending":
        throw suspender;
      case "error":
        throw response;
      default:
        return response;
    }
  };
 
  return { read };
}
 
export default wrapPromise;

만약 promise가 pending 상태라면 promise를 throw하는 것을 볼 수 있습니다.

이처럼 promise를 throw해주는 로직이 들어가야 suspense가 제대로 동작할 수 있습니다. https://github.com/pmndrs/suspend-react 라는 패키지를 사용하면 편하게 throw할 수 있습니다.

다만, react-query에는 이미 이 기능이 구현되어 있습니다. v4 기준 suspense 옵션, v5 기준 useSuspense~ hooks를 사용하면 됩니다.

Suspense는 어떻게 fallback UI와 Children을 구분해서 보여줄 수 있을까요?

Reconciler가 렌더링하는 과정에서 SuspenseComponent를 만날 경우, updateSuspenseComponent를 호출합니다.

function updateSuspenseComponent(current, workInProgress, renderLanes) {
  const nextProps = workInProgress.pendingProps;
 
  let showFallback = false;
  const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
  if (
    didSuspend ||
    shouldRemainOnFallback(current, workInProgress, renderLanes)
  ) {
    showFallback = true;
    workInProgress.flags &= ~DidCapture;
  }
....

내부의 didSuspend는 로딩중인지를 기록하는 변수입니다.

shouldRemainOnFallback는 Concurrent mode를 지원하기 위한 함수로, fallback UI를 유지해야하는 상황에서 true로 설정됩니다.

로딩중이거나, fallback UI를 유지해야하는 경우 showFallbacktrue로 설정됩니다.

if (current === null) {
    // Initial mount
    const nextPrimaryChildren = nextProps.children;
    const nextFallbackChildren = nextProps.fallback;
 
    if (showFallback) {
      pushFallbackTreeSuspenseHandler(workInProgress);
 
      const fallbackFragment = mountSuspenseFallbackChildren(
        workInProgress,
        nextPrimaryChildren,
        nextFallbackChildren,
        renderLanes,
      );
      
      const primaryChildFragment: Fiber = (workInProgress.child: any);
      primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
        renderLanes,
      );
      
      workInProgress.memoizedState = SUSPENDED_MARKER;
      return fallbackFragment;
    } else {
      pushPrimaryTreeSuspenseHandler(workInProgress);
      return mountSuspensePrimaryChildren(
        workInProgress,
        nextPrimaryChildren,
        renderLanes,
      );
    }
  }

만약 showFallbacktrue라면, mountSuspenseFallbackChildren을 호출합니다.

  • mountSuspensePrimaryChildren 은 이렇게 동작합니다.

    function mountSuspenseFallbackChildren(
      workInProgress: Fiber,
      primaryChildren: $FlowFixMe,
      fallbackChildren: $FlowFixMe,
      renderLanes: Lanes,
    ) {
      const mode = workInProgress.mode;
      const progressedPrimaryFragment: Fiber | null = workInProgress.child;
     
      const primaryChildProps: OffscreenProps = {
        mode: 'hidden',
        children: primaryChildren,
      };
     
      let primaryChildFragment;
      let fallbackChildFragment;
      if (
        (mode & ConcurrentMode) === NoMode &&
        progressedPrimaryFragment !== null
      ) {
        primaryChildFragment = progressedPrimaryFragment;
        primaryChildFragment.childLanes = NoLanes;
        primaryChildFragment.pendingProps = primaryChildProps;
     
        if (enableProfilerTimer && workInProgress.mode & ProfileMode) {
          primaryChildFragment.actualDuration = 0;
          primaryChildFragment.actualStartTime = -1;
          primaryChildFragment.selfBaseDuration = 0;
          primaryChildFragment.treeBaseDuration = 0;
        }
     
        fallbackChildFragment = createFiberFromFragment(
          fallbackChildren,
          mode,
          renderLanes,
          null,
        );
      } else {
        primaryChildFragment = mountWorkInProgressOffscreenFiber(
          primaryChildProps,
          mode,
          NoLanes,
        );
        fallbackChildFragment = createFiberFromFragment(
          fallbackChildren,
          mode,
          renderLanes,
          null,
        );
      }
     
      primaryChildFragment.return = workInProgress;
      fallbackChildFragment.return = workInProgress;
      primaryChildFragment.sibling = fallbackChildFragment;
      workInProgress.child = primaryChildFragment;
      return fallbackChildFragment;
    }

    현재 모드가 ConcurrentMode가 아니고 progressedPrimaryFragment(기존 작업)가 존재하면 fallbackChildFragment를 렌더링하고, ConcurrentMode이거나 기존 작업이 없다면 mountWorkInProgressOffscreenFiber를 호출하여 fallbackChildFragmentprimaryChildFragment을 모두 렌더링합니다.

    단, mountWorkInProgressOffscreenFiber에서 렌더링되는 Children은 primaryChildProps에서 정의된 대로 hidden 즉, **화면에 나타나지 않고 백그라운드에서 렌더링(DOM에 미반영)**됩니다.

렌더링할 준비가 되었다면, showFallback이 false로 설정되고 mountSuspensePrimaryChildren를 호출합니다.

  • mountSuspensePrimaryChildren 은 이렇게 동작합니다.

    function mountSuspensePrimaryChildren(
      workInProgress: Fiber,
      primaryChildren: $FlowFixMe,
      renderLanes: Lanes,
    ) {
      const mode = workInProgress.mode;
      const primaryChildProps: OffscreenProps = {
        mode: 'visible',
        children: primaryChildren,
      };
      const primaryChildFragment = mountWorkInProgressOffscreenFiber(
        primaryChildProps,
        mode,
        renderLanes,
      );
      primaryChildFragment.return = workInProgress;
      workInProgress.child = primaryChildFragment;
      return primaryChildFragment;
    }

    mountWorkInProgressOffscreenFiber를 호출하여 primaryChildFragment를 렌더링합니다.

    단, 이번에는 modevisible, 즉, DOM에 포함되어 화면에 보여집니다.

Suspense는 어떻게 showFallback을 결정할까요?

아래는 renderRootConcurrent함수의 일부분입니다. 엄청 길지만… workLoopConcurrent라는 함수를 try catchdo while로 감싸놓은 것을 확인할 수 있습니다.

outer: do {
    try {
      if (
        workInProgressSuspendedReason !== NotSuspended &&
        workInProgress !== null
      ) {
        const unitOfWork = workInProgress;
        const thrownValue = workInProgressThrownValue;
        resumeOrUnwind: switch (workInProgressSuspendedReason) {
          case SuspendedOnError: {
            workInProgressSuspendedReason = NotSuspended;
            workInProgressThrownValue = null;
            throwAndUnwindWorkLoop(root, unitOfWork, thrownValue);
            break;
          }
          case SuspendedOnData: {
            const thenable: Thenable<mixed> = (thrownValue: any);
            if (isThenableResolved(thenable)) {
              workInProgressSuspendedReason = NotSuspended;
              workInProgressThrownValue = null;
              replaySuspendedUnitOfWork(unitOfWork);
              break;
            }
 
            const onResolution = () => {
              if (
                workInProgressSuspendedReason === SuspendedOnData &&
                workInProgressRoot === root
              ) {
                workInProgressSuspendedReason = SuspendedAndReadyToContinue;
              }
 
              ensureRootIsScheduled(root);
            };
            thenable.then(onResolution, onResolution);
            break outer;
          }
          case SuspendedOnImmediate: {
            workInProgressSuspendedReason = SuspendedAndReadyToContinue;
            break outer;
          }
          case SuspendedOnInstance: {
            workInProgressSuspendedReason =
              SuspendedOnInstanceAndReadyToContinue;
            break outer;
          }
          case SuspendedAndReadyToContinue: {
            const thenable: Thenable<mixed> = (thrownValue: any);
            if (isThenableResolved(thenable)) {
              // The data resolved. Try rendering the component again.
              workInProgressSuspendedReason = NotSuspended;
              workInProgressThrownValue = null;
              replaySuspendedUnitOfWork(unitOfWork);
            } else {
              // Otherwise, unwind then continue with the normal work loop.
              workInProgressSuspendedReason = NotSuspended;
              workInProgressThrownValue = null;
              throwAndUnwindWorkLoop(root, unitOfWork, thrownValue);
            }
            break;
          }
          case SuspendedOnInstanceAndReadyToContinue: {
            switch (workInProgress.tag) {
              case HostComponent:
              case HostHoistable:
              case HostSingleton: {
                const hostFiber = workInProgress;
                const type = hostFiber.type;
                const props = hostFiber.pendingProps;
                const isReady = preloadInstance(type, props);
                if (isReady) {
                  workInProgressSuspendedReason = NotSuspended;
                  workInProgressThrownValue = null;
                  const sibling = hostFiber.sibling;
                  if (sibling !== null) {
                    workInProgress = sibling;
                  } else {
                    const returnFiber = hostFiber.return;
                    if (returnFiber !== null) {
                      workInProgress = returnFiber;
                      completeUnitOfWork(returnFiber);
                    } else {
                      workInProgress = null;
                    }
                  }
                  break resumeOrUnwind;
                }
                break;
              }
              default: {
                break;
              }
            }
            workInProgressSuspendedReason = NotSuspended;
            workInProgressThrownValue = null;
            throwAndUnwindWorkLoop(root, unitOfWork, thrownValue);
            break;
          }
          case SuspendedOnDeprecatedThrowPromise: {
            workInProgressSuspendedReason = NotSuspended;
            workInProgressThrownValue = null;
            throwAndUnwindWorkLoop(root, unitOfWork, thrownValue);
            break;
          }
          case SuspendedOnHydration: {
            resetWorkInProgressStack();
            workInProgressRootExitStatus = RootDidNotComplete;
            break outer;
          }
          default: {
            throw new Error(
              'Unexpected SuspendedReason. This is a bug in React.',
            );
          }
        }
      }
 
      workLoopConcurrent();
      break;
    } catch (thrownValue) {
      handleThrow(root, thrownValue);
    }
  } while (true);

workLoopConcurrent는 Fiber를 돌면서 beginWork 즉, 렌더링을 담당합니다. 여기서 workInProgressSuspendedReasonSuspendedOnData일 경우(비동기 작업이 진행중일 경우), thenable.then(onResolution, onResolution)을 등록하고 루프를 빠져나옵니다. 이러한 방식으로 비동기 작업이 완료되는 것을 기다리면서 무한 루프에 빠지지 않고 다른 작업을 수행할 수 있습니다.

handleThrowbeginWork를 수행하는 중에 throw된 errorpromise를 포착하고, 이것이 SuspenseException인지 확인합니다.

SuspenseException이라면, thrownValuethenable로 만들고 workInProgressSuspendedReasonSuspendedOnData로 만들어 위의 do while에서 조건에 잡히도록 만들어줍니다.

function handleThrow(root: FiberRoot, thrownValue: any): void {
  resetHooksAfterThrow();
  resetCurrentDebugFiberInDEV();
  ReactCurrentOwner.current = null;
 
  if (thrownValue === SuspenseException) {
    thrownValue = getSuspendedThenable();
    workInProgressSuspendedReason =
      shouldRemainOnPreviousScreen() &&
      !includesNonIdleWork(workInProgressRootSkippedLanes) &&
      !includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
        ? SuspendedOnData
        : SuspendedOnImmediate;
  } else if (thrownValue === SuspenseyCommitException) {
    thrownValue = getSuspendedThenable();
    workInProgressSuspendedReason = SuspendedOnInstance;
  } else if (thrownValue === SelectiveHydrationException) {
    workInProgressSuspendedReason = SuspendedOnHydration;
  } else {
    const isWakeable =
      thrownValue !== null &&
      typeof thrownValue === 'object' &&
      typeof thrownValue.then === 'function';
 
    workInProgressSuspendedReason = isWakeable
      ? SuspendedOnDeprecatedThrowPromise
      : SuspendedOnError;
  }
 
  workInProgressThrownValue = thrownValue;
 
...

ErrorBoundary와 비슷한가요?

대수적 효과, 제어의 역전 등으로 불리우는 개념을 적용했다는 점에서 유사하다고 할 수 있을 것 같습니다. 즉, 특정 상태를 처리하는 로직을 외부에 위임합니다.

따라서 사용했을 경우에 장점도 유사합니다. 선언적으로 로딩 처리를 할 수 있으며, 일관된 코드 작성이 가능합니다. 그리고 Suspense는 data fetching이 성공했음을 보장합니다. 따라서 Children에서 사용하는 data는 항상 존재합니다(이는 react query v5에서 useSuspense~ hooks로 구현되었습니다.)

다만, ErrorBoundary는 Class Component의 라이프사이클 메서드인 getDerivedStateFromError를 사용하여 에러를 포착했다면 Suspense리액트의 렌더링 엔진에서 promise를 포착한다는 차이가 있습니다.

참고

useMutation에는 Suspense가 적용되지 않습니다.

tkdodo의 코멘트에 따르면(벌써 몇년 전이긴 하지만…) 아무도 구현한 사람이 없어서 구현되지 않았다고 합니다.