리액트의 Suspense는 어떻게 동작할까?
May 06, 2024
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;
}
}
- Suspense 컴포넌트는 isPending 이라는 내부 상태를 갖고 있다.
- ComponentDidCatch 메서드를 통해서 에러가 throw 되었을 경우 캐치한다.
- Promise 가 Throw 되었을 경우 pending 상태를 true 로 변경한다.
- 해당 Promise 가 이행되면 pending 상태를 false 로 변경한다.
- 해당 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)를 활용한 것이다.🥹