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
를 반환한다. ↩︎
댓글
댓글 쓰기