Suspense란?

  • 서스펜스를 사용하면 자식 컴포넌트가 로딩을 완료 할 때 까지 폴백 UI를 노출 할 수 있다.
  • 서스펜스로 감싸지면, 리액트는 자식에게 필요한 모든 코드와 데이터가 로드 될 때 까지 로딩 폴백을 표시한다.
  • 기존에는 JS 번들을 스플리팅하고 웹 자원 중 코드를 lazy loading 하는데 쓰였지만, React 18 부터는 어떤 것이든 기다릴 수 있는 기능으로 확장되었다.
    • 즉, 이미지 / 스크립트 / 그 밖의 비동기 작업을 기다리는데에 모두 사용 될 수 있다.

const SomeComponent =()=>{
	return ( 
	  <Suspense fallback={<Loading />}>
        <OtherComponent />
      </Suspense>
   )
}

Suspense가 활성화 되는 상태

  • Relay 및 Next.js와 같은 Suspense 도입 프레임워크를 사용한 데이터 페칭
  • lazy 를 사용한 지연 로딩 컴포넌트 코드
  • Suspense 는 Effect 나 이벤트 핸들러 내부에서. 페칭하는 경우를 감지하지 않는다.

Suspense는 어떻게 동작할까?

  • Suspense 는 하위 children들의 비동기 상태를 감지 할 수 있다.
  • 그렇다면 어떻게 알 수 있는걸까?
  • 아래는 리액트 코어 팀의 작성한 컨셉 코드이다.
function wrapPromise(promise) {
  let status = 'pending'; // 최초 상태
  let result;let suspender = promise.then(
    (r) => {
      status = 'success';  // 성공 시
      result = r;
    },
    (e) => {
      status = 'error';  // 실패 시 
      result = e;
    }
  );return {
    read() {
      if (status === 'pending') {
        throw suspender;
      } else if (status === 'error') {
        throw result; 
      } else if (status === 'success') {
        return result; 
      }
    },
  };
}
  • wrapPromise 는 Promise 를 한번 더 감싸서, pending 혹은 error 시에는 throw 를 해주고, success 시에는 응답값을 return 한다.
  • 이를 활용한 코드를 통해서 wrapPromise 가 어떻게 사용되는지 살펴보자.

const 비동기통신 = () => {
  const promise = new Promise((resolve) => {
    return setTimeout(() => {
      resolve("hi");
    }, 1000);
  });

  return wrapPromise(promise);
};

const resource = 비동기통신();

const ChildrenComponent = () => {
  const 결과 = resource.read();

  return (
    <div>
      <div>completed.</div>
      <div>
        <span>{결과}</span>
      </div>
    </div>
  );
};

export default ChildrenComponent;
  • 위와 같이 반환되는 Promise를 wrapPromise 함수로 한번 랩핑한다.
  • 그리고 해당 ChildrenComponent 사용처에서 Suspense를 추가하면 된다.
<Suspense fallback={<div>loading..</div>}>
  <ChildrenComponent />
</Suspense>

그렇다면 Suspense 컴포넌트가 아래에서 throw 된 Promise를 받아주는 역할을 하고 있는 것 같다. 아래의 Suspense 코드를 보자

import React from "react";

export interface SuspenseProps {
  fallback: React.ReactNode;
}

interface SuspenseState {
  pending: boolean;
  error?: any;
}

function isPromise(i: any): i is Promise<any> {
  return i && typeof i.then === "function";
}

export default class Suspense extends React.Component<
  SuspenseProps,
  SuspenseState
> {
  public state: SuspenseState = {
    pending: false
  };

  public componentDidCatch(catchedPromise: any) {
    if (isPromise(catchedPromise)) {
      this.setState({ pending: true });

      catchedPromise
        .then(() => {
          this.setState({ pending: false });
        })
        .catch((err) => { // Promise 가 에러 throw 시 상위의 에러바운더리로 재전파
          this.setState({ error: err || new Error("Suspense Error") });
        });
    } else {
      throw catchedPromise;
    }
  }

  public componentDidUpdate() {
    if (this.state.pending && this.state.error) {
      throw this.state.error;
    }
  }

  public render() {
    return this.state.pending ? this.props.fallback : this.props.children;
  }
}
  1. Suspense 컴포넌트는 isPending 이라는 내부 상태를 갖고 있다.
  2. ComponentDidCatch 메서드를 통해서 에러가 throw 되었을 경우 캐치한다.
  3. Promise 가 Throw 되었을 경우 pending 상태를 true 로 변경한다.
  4. 해당 Promise 가 이행되면 pending 상태를 false 로 변경한다.
  5. 해당 Promise 에서 다시 에러가 발생하면 상위의 에러바운더리 컴포넌트로 다시한번 에러를 재전파한다.

그렇다면 레이지 로딩 시에는?

function lazyInitializer<T>(payload: Payload<T>): T {
  if (payload._status === Uninitialized) {
    const ctor = payload._result;
    const thenable = ctor();
    // Transition to the next state.
    // This might throw either because it's missing or throws. If so, we treat it
    // as still uninitialized and try again next time. Which is the same as what
    // happens if the ctor or any wrappers processing the ctor throws. This might
    // end up fixing it if the resolution was a concurrency bug.
    thenable.then(
      moduleObject => {
        if (payload._status === Pending || payload._status === Uninitialized) {
          // Transition to the next state.
          const resolved: ResolvedPayload<T> = (payload: any);
          resolved._status = Resolved;
          resolved._result = moduleObject;
        }
      },
      error => {
        if (payload._status === Pending || payload._status === Uninitialized) {
          // Transition to the next state.
          const rejected: RejectedPayload = (payload: any);
          rejected._status = Rejected;
          rejected._result = error;
        }
      },
    );
    if (payload._status === Uninitialized) {
      // In case, we're still uninitialized, then we're waiting for the thenable
      // to resolve. Set it as pending in the meantime.
      const pending: PendingPayload = (payload: any);
      pending._status = Pending;
      pending._result = thenable;
    }
  }
  if (payload._status === Resolved) {
    const moduleObject = payload._result;
//....생략
    return moduleObject.default;
  } else {
    throw payload._result;
  }
}
  • 레이지로딩에서 역시 throw 를 사용하여 현재 로딩 상태를 상위의 Suspense에 전파한다.
  • 즉, payload 의 상태가 Uninitialized 혹은 resolved 가 아닌 경우 payload._result 를 throw 한다.

그렇다면 Suspense 가 활성화 되는 상태를 다시한번 보자

Suspense 는 Effect 나 이벤트 핸들러 내부에서 페칭하는 경우를 감지하지 않는다.

  • Effect 내부에서 페칭하는 경우
    • useEffect는 컴포넌트의 렌더링 이후에 비동기 작업을 처리하기 위해 사용된다. 하지만 Suspense 는 렌더링 중에 대기 상태를 처리하기 위해 설계되어 있다.
    • Suspense는 컴포넌트의 렌더링 중에 발생하는 작업을 추적하고, 이에 따라서 폴백을 노출하지만, Effect 내에서 발생하는 작업은 렌더링과 직접적인 관련이 업승므로 Suspense 가 해당 작업을 추적 할 수 없다.
  • 이벤트 핸들러 내부에서 페칭하는 경우
    • 리액트는 모든 이벤트를 상위 root 엘리먼트에 등록하여 처리한다.
    • 따라서, 이벤트 핸들러 내부에서 fetching 되어 발생되는 에러(throw 되는 Promise)는 Suspense 의 실행컨텍스트 내에 존재하지 않는다.

그러면 react-query 와 같은 라이브러리에서 Suspense는 어떻게 활성화 되나?

  • 리액트 쿼리의 useBaseQuery 원본 코드를 살펴보면 아래와 같다.

// .... 생략 
    // 옵션 변경 시 옵저버에서 result 를 업데이트 처리를 하는 effect
    React.useEffect(() => {
    observer.setOptions(defaultedOptions, { listeners: false })
  }, [defaultedOptions, observer])


  // 서스펜스를 처리한다.
  if (shouldSuspend(defaultedOptions, result)) {
    // 위의 effect 와 동일한 역할을 한다.
    // 왜냐하면 위의 useEffect는 suspense 에서 실행되지 않지만,
    // 컴포넌트가 다시 마운트 되지 않기 때문에 observer의 데이터의 업데이트가 필요하기 때문 
    throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
  }

  // 에러바운더리를 처리한다.
  if (
    getHasError({
      result,
      errorResetBoundary,
      throwOnError: defaultedOptions.throwOnError,
      query: client
        .getQueryCache()
        .get<
          TQueryFnData,
          TError,
          TQueryData,
          TQueryKey
        >(defaultedOptions.queryHash),
    })
  ) {
    throw result.error
  }

// .... 생략 
  • 보면 useEffect 를 사용하여 쿼리의 옵션이 변경 될 때마다 setOption 메서드를 호출하여 쿼리 result 를 최신화 시킨다.
  • 그런데 아래의 Suspense 옵션이 true일 때 처리되는 코드를 보면, 위의 effect 와 동일한 역할을 하되, fetchOptimistic 호출 결과를 throw 한다. 참고로 QueryObserver 의 fetchOptimistic 의 내부 코드는 아래와 같다.

  fetchOptimistic(
    options: QueryObserverOptions<
      TQueryFnData,
      TError,
      TData,
      TQueryData,
      TQueryKey
    >,
  ): Promise<QueryObserverResult<TData, TError>> {
    const defaultedOptions = this.#client.defaultQueryOptions(options)

    const query = this.#client
      .getQueryCache()
      .build(this.#client, defaultedOptions)
    query.isFetchingOptimistic = true

    return query.fetch().then(() => this.createResult(query, defaultedOptions))
  }
  • 따라서 promise 가 effect 외부에서 throw 되어서 Suspense 로의 전파가 가능해진다.
  • 아래의 에러바운더리 옵션이 true 일때도 마찬가지이다.

제대로 살펴보면 리액트 자체의 특별한 기능이 아니다. 자바스크립트의 기본적인 원리 (throw, Promise)를 활용한 것이다.🥹


Ref