React 18 이전 Suspense는 실험적으로 사용되었으며, React.lazy를 통해 코드 스플리팅만 가능했었다.
React 18이 되면서 Suspense를 사용하면 코드 스플리팅, 비동기 데이터, 이미지 레이지로딩 등으로 기능이 확장됐다.
Code Spliting
const ProfilePage = React.lazy(() => import('./ProfilePage')); // 지연 로딩
// 프로필을 불러오는 동안 스피너를 표시합니다.
<Suspense fallback={<Spinner />}>
<ProfilePage />
</Suspense>;
위 코드처럼 React.lazy를 통해 첫 번째 렌더링 때 ProfilePage 컴포넌트를 불러오지 않고, 최초 렌더링 이후에 컴포넌트를 지연시켜 불러오는 코드 스플리팅 역할을 한다.
지연되는 컴포넌트를 Suspense로 감싸면, 지연되는 동안 Suspense에서 props로 전달받은 fallback을 보여준다.
Data Fetching
Fetch-on-render
const App = () => {
const [userDetails, setUserDetails] = useState({});
useEffect(() => {
fetchUserDetails().then(setUserDetails);
}, []);
if (!userDetails.id) return <p>Fetching user details...</p>;
return (
<div className="app">
<h2>Simple Todo</h2>
<UserWelcome user={userDetails} />
<Todos />
</div>
);
};
위 방식은 컴포넌트가 마운트 된 후 Data Fetching을 시작한다. 즉, 컴포넌트 렌더링을 먼저 시작하고 useEffect나 componentDidMount로 비동기 처리를 한다.
여기서 문제점은 Todos 컴포넌트는 fetchiUserDetails()
가 resolve되기 전까지 보여지지 않는다. 만약 Todos에서도 fetch 요청이 있다면 병렬적으로 진행되지 않으므로 waterfall
문제가 발생한다.
Fetch-then-render
function fetchUserDetailsAndTodos() {
return Promise.all([fetchUserDetails(), fetchTodos()]).then(
([userDetails, todos]) => ({ userDetails, todos })
);
}
const fetchDataPromise = fetchUserDetailsAndTodos(); // We start fetching here
const App = () => {
const [userDetails, setUserDetails] = useState({});
const [todos, setTodos] = useState([]);
useEffect(() => {
fetchDataPromise.then((data) => {
setUserDetails(data.userDetails);
setTodos(data.todos);
});
}, []);
return (
<div className="app">
<h2>Simple Todo</h2>
<UserWelcome user={userDetails} />
<Todos todos={todos} />
</div>
);
};
위 방식은 컴포넌트가 렌더링 되기 이전에 Data Fetching을 시작한다.
비동기 요청하는 로직을 App 컴포넌트 밖으로 옮기면서, 컴포넌트가 마운트 되기 이전에 데이터를 요청하고 Promise.all을 통해 비동기 작업들의 동시성을 보장할 수 있다.
하지만, 비동기 작업 중 더 느린 요청이 있다면 해당 요청이 완료될 때까지 기다려야 렌더링이 되고, 요청이 하나라도 실패하면 다른 요청도 reject가 되기 때문에 높은 결합도를 만들 수 있어 좋지 않다.
Render-as-you-fetch
각각의 컴포넌트에서 자신의 데이터를 각자 책임지고 요청할 수 있도록 도입한 것이 Suspense
다.
const data = fetchData();
const App = () => (
<>
<Suspense fallback={<p>Fetching user details...</p>}>
<UserWelcome />
</Suspense>
<Suspense fallback={<p>Loading todos...</p>}>
<Todos />
</Suspense>
</>
);
const UserWelcome = () => {
const userDetails = data.userDetails.read();
// code to render welcome message
};
const Todos = () => {
const todos = data.todos.read();
// code to map and render todos
};
위 방식은 비동기 작업과 렌더링을 동시에 시작하여 즉시 초기 상태를 렌더링(fallback rendering)하고, 비동기 작업이 완료되면 다시 렌더링한다.
데이터 요청을 React-Query나 SWR이 아닌 fetch나 axios로 사용해서 Suspense를 적용하면 제대로 동작하지 않는다.
Suspense 동작 방식
Suspense가 감싸고 있는 컴포넌트에서 비동기 요청을 하고, 아직 데이터가 준비되지 않았다면(= 요청이 resolve되지 않았다면) Suspense에 있는 fallback이 렌더링되고, 요청이 resolve되면 해당 컴포넌트로 다시 렌더링이 된다.
내가 가장 궁금해했던 부분은 그렇다면 Suspense는 이를 어떻게 감지하고 동작하는 걸까?
//wrapPromise.js
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;
- wrapPromise는 promse를 인자로 받는다.
- Promise는 기본적으로 pending 상태이며, pending 상태이면 Promise를 던지고 fallback UI를 렌더링한다.
- 만약 error 상태라면 reject된 결과 값을 던지고, 이를 ErrorBoundary가 전달 받아 처리하게 된다.
- Promise가 success이면 resolve된 결과 값을 반환하고, 반환된 데이터로 다시 렌더링하게 된다.
Suspense가 인지할 수 있도록 wrapPromise를 만들었으니, 이를 사용한 fetch 코드는 다음과 같다.
import wrapPromise from "./wrapPromise";
function fetchData(url) {
const promise = fetch(url)
.then((res) => res.json())
.then((res) => res.data);
return wrapPromise(promise);
}
export default fetchData;
axios & custom hook
위 예시는 fetch를 사용했다면, axios를 사용한 사례와 이를 간편하게 사용할 수 있도록 custom hook을 이용한 방법도 있다.
// src/useGetData.js
import { useState, useEffect } from "react";
import axios from "axios";
const promiseWrapper = (promise) => {
let status = "pending";
let result;
const s = promise.then(
(value) => {
status = "success";
result = value;
},
(error) => {
status = "error";
result = error;
}
);
return () => {
switch (status) {
case "pending":
throw s;
case "success":
return result;
case "error":
throw result;
default:
throw new Error("Unknown status");
}
};
};
function useGetData(url, setData = data => data) {
const [resource, setResource] = useState(null);
useEffect(() => {
const getData = async () => {
const promise = axios.get(url).then((response) => setData(response.data));
setResource(promiseWrapper(promise));
};
getData();
}, [url]);
return resource;
}
export default useGetData;
useGetData 커스텀 훅은 인자로 요청할 url과 응답 데이터를 변경하여 새로운 데이터로 반환하는 함수 setData를 전달 받는다.
그리고 위에서 만든 promiseWrapper를 감싸주면 된다.
이를 컴포넌트에 적용하면 다음과 같다.
// Parent
...
<Suspense fallback={<TableLoading />}>
<FetchTable params={params} />
</Suspense>
...
// Children
const FetchTable = ({ params }) => {
const problemData = useGetData(
`achievement?id=${params.id}`,
getBackjoonSolvedData,
);
return (
<>
{problemData.map((problem, index) => (
...
))}
</>
);
};
export default FetchTable;
참고
- React Suspense for Data Fetching with Axios in React 18 (opens in a new tab)
- React Suspense & ErrorBoundary 직접 만들기 (opens in a new tab)
- Conceptual Model of React Suspense (opens in a new tab)
- React Suspense와 lazy Loading (opens in a new tab)
- [ko]React 공식문서 - Suspense (opens in a new tab)
- 0213-suspense-in-react-18.md (opens in a new tab)
- Suspense for Data Fetching의 작동 원리와 컨셉 (feat.대수적 효과) (opens in a new tab)
- React Suspense와 비동기 통신 (opens in a new tab)
- kakao FE 기술블로그 - Suspense와 선언적으로 Data fetching처리 (opens in a new tab)