Next.js 13 App Router Client Component에서 Loading Convnetion 사용하기
Loading convention
Next.js 13 app router(또는 app dir)의 loading convention을 사용하려 했다. 그러나 MUI를 사용하기 위해서 page는 클라이언트 컴포넌트가 되어야만 했다.
공식 문서를 보니, 예제들은 전부 서버 컴포넌트를 기준으로 하는 것 같았다. 다행히 React Suspense 기반이라는 내용이 친절하게 잘 적혀있어서 이를 바탕으로 해결 방법을 모색해 보았다.
React Suspense
리액트 서스펜스는 <Suspense> 컴포넌트를 사용하여 해당 컴포넌트의 children들은 <Suspense> 컴포넌트의 부모 컴포넌트에 필요한 리소스가 로드되기 전까지 fallback 프롭에 지정된 ReactNode를 렌더링하게 해준다. 따라서 서스펜스 컴포넌트를 사용한다면 useEffect에서 이것 저것 리소스 로딩 로직을 잔뜩 짜고, 이를 useState를 사용해 핸들링하고, 이걸 또 렌더링 부분에서 조건문(주로 삼항 연산자)을 사용해 렌더할 필요 없이 선언적으로 코딩할 수 있다.
도대체 어떻게 리소스가 로딩중이란 걸 알 수 있나
<Suspense> 컴포넌트(이하 서스펜스 컴포넌트)의 children에서 throw된 내용을 기반으로 로딩중인지, 완료되었는지, 아니면 에러가 발생했는지 구분한다고 한다.
- “pending” 상태인 Promise가 throw됐다면 로딩 중.
fallback프로퍼티의ReactNode렌더. - Error가 throw됐다면 일반적인 에러 핸들링 로직대로 처리. 즉 throw.
- 둘 다 아니라면 로딩 완료로 간주,
fallback대신children을 렌더.
Next.js 13 App Router의 Client Component에서 로딩 상태 구현
앞서 공식 문서에서 Next.js 13 App Rouer의 loading 컨벤션이 내부적으로 레이아웃 -> 로딩(서스펜스 컴포넌트) -> 페이지 컴포넌트 순서로 렌더링된다는 것을 알았으니, 페이지에서 적절하게 서스펜스 컴포넌트가 작동할 수 있도록 throw Promise를 해주면 될 것 같았다…만
언제나 그렇듯 이 기능을 이 페이지에서만 쓸 것이 아니기 때문에 적절히 모듈로 만들어 둘 필요가 있었다.
그러던 중 발견한 카카오엔터 기술블로그. useFetch<I, T> 훅을 만들어서 쓰고 있더라.
결론부터 말하자면, 당연히 위 방법으로 잘 작동한다. 기술 정보가 필요한 사람은 여기까지만 읽어도 상관없다.
useFetch<I, T> 훅의 이해
훅은 다음과 같이 생겼다.
export function useFetch<I, T>(fetch: (arg: I) => Promise<T>, arg: I) {
function resolvePromise(result: T) {
setStatus("fulfilled");
setResult(result);
}
function rejectPromise(error: Error) {
setStatus("error");
setError(error);
}
const [promise, setPromise] = useState<Promise<void>>();
const [status, setStatus] = useState<"pending" | "fulfilled" | "error">("pending");
const [result, setResult] = useState<T>();
const [error, setError] = useState<Error>();
useEffect(() => {
setStatus("pending");
setPromise(fetch(arg).then(resolvePromise, rejectPromise));
}, [arg]);
if (status === "pending" && promise) {
throw promise;
}
if (status === "error") {
throw error;
}
return result;
}
처음 봤을 땐 굉장히 혼란스러웠다. 무엇보다 아래에서 선언된 setStatus, setResult 등을 위에서 사용할 수 있는 것도 낯설었다. 타입스크립트는 스코프 내에서라면 함수 선언시 함수보다 다음 줄에서 초기화된 변수도 사용할 수 있다는 걸 이 코드 덕분에 알았다.
아무튼, 쭉 살펴보니…
Promise<T>를 반환하는I형식의 인자 1개를 가지는 대리자를 첫 번째 인자로, 그리고 그 대리자에 첫 번째 인수에 들어갈I형식의 값을 두 번째 인자로 받는 훅이다.- 두 번째 인자(arg)가 변하면
status를"pending"으로 변경하고promise에 첫 번째 인자의Promise<T>대리자를 호출하고 그 결과에 따라resolvePromise또는rejectPromise를 호출하는Promise<void>를 할당한다.1 - 위 단계에서 비동기로
fetch대리자가 돌게 되고, 시간이 걸리는 작업이 진행된다. fetch대리자가 즉시 처리되더라도, 일단 현재status는"pending"이므로throw promise가 호출되어Promise { <state>: "pending }"이 throw되며, 이는 곧 서스펜스 컴포넌트가fallback프로퍼티의 리액트 노드를 렌더링하게 한다. 즉 로딩 컴포넌트가 보이게 된다.- 이제
fetch대리자의 처리 결과에 따라 분기된다.- 성공적으로 처리됐을 경우,
resolvePromise가 호출되어status가"fulfilled"로 변경되고result가T형식의fetch대리자의resolve된 값으로 설정된다. 상태가 변경됐으므로 훅을 사용한 컴포넌트가 다시 렌더되며 훅 역시 다시 호출되고,result가 반환된다. 재 렌더링 과정에서Promise { <state>: "pending" }이 throw되지 않았으므로 서스펜스 컴포넌트의fallback프로퍼티는 렌더링되지 않고,children이 렌더링되어 로딩이 완료된 페이지가 보이게 된다. - 성공적으로 처리되지 않았을 경우,
rejectPromise가 호출되어status가"error"로 변경되고error가 설정되며, 상태 변경에 따라 훅을 사용한 컴포넌트가 다시 렌더링되며if (status === "error")조건에 걸려 상태에 저장된error를 던진다.
- 성공적으로 처리됐을 경우,
사실 글을 쓰기 시작할 때까지만 해도 제대로 이해가 안 되었던 상태인데, 하나씩 정리하며 다시 보니 완벽히 이해하게 됐다. 이래서 다른 사람들 코드를 많이 봐야 하나보다.
Promise.then(resolve, reject) 역시 비동기로 실행된다. 즉
Promise를 반환한다. ↩︎
댓글
댓글 쓰기