React Suspense를 통해 child component가 loading 상태일 때 fallback UI를 대신 보여줄 수 있다. 다음 예제를 살펴보자.
import { Suspense } from "react";
import ExampleChild from "./ExampleChild";
const ExamplePage = () => {
return (
<div>
<h3>Example Page</h3>
<Suspense fallback={<div>loading...</div>}>
<ExampleChild />
</Suspense>
</div>
);
};
export default ExamplePage;
ExampleChild.tsx
import axios from "axios";
import { use } from "react";
const getOrderList = async () => {
const result = await axios.get("http://localhost:4000/orders");
return result.data;
};
const orderListResult = getOrderList();
const ExampleChild = () => {
const orderList = use(orderListResult);
return (
<div>
{orderList.map((order) => (
<p key={order.id}>{order.name}</p>
))}
</div>
);
};
export default ExampleChild;
위의 예제에서 ExampleChild component는 getOrderList function을 통해 api server로 order list data를 요청하고 있고 그에 대한 return 값을 orderListResult variable에 assign해주고 있다. 그리고 orderListResult는 promise object이므로 react use api를 통해 promise의 value를 return 해준다.
여기서 react use api가 promise의 값을 return하고 component의 loading 상태가 변경될 때 까지 ExampleChild component를 wrapping하고 있는 Suspense의 fallback에 설정한 UI가 ExampleChild component 자리에 표시된다.
Suspense를 적용하면 React Query와 같은 library를 통해 data를 fetch할 때에도 data를 fetch하는 component에서 fetching 관련 logic을 덜어낼 수 있다. 예를 들어 Suspense를 적용하지 않고 fetching 관련 logic을 처리하기 위해서 보통 다음과 같이 data를 fetch하는 component에서 fetching 상태에 따라 loading UI를 표시해준다.
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { use } from "react";
const ExampleChild= () => {
const { data:orderList, isPending } = useQuery({
queryKey: ["orderList"],
queryFn: async () => {
const result = await axios.get(`http://localhost:4000/orders`);
return result.data;
},
});
if (isPending) {
return <div>loading...</div>;
}
return (
<div>
{orderList.map((order: any) => (
<p key={order.id}>{order.name}</p>
))}
</div>
);
};
export default ExampleChild;
반면 ExampleChild component를 Suspense로 wrapping하고 있다면 다음과 같이 useSuspenseQuery를 통해 data를 fetching하는 component에서 loading 부분을 제외하고 data처리 logic에만 집중할 수 있다.
import { useSuspenseQuery } from "@tanstack/react-query";
import axios from "axios";
const ExampleChild = () => {
const { data:orderList } = useSuspenseQuery({
queryKey: ["orderList"],
queryFn: async () => {
const result = await axios.get(`http://localhost:4000/orders`);
return result.data;
},
});
return (
<div>
{orderList.map((order) => (
<p key={order.id}>{order.name}</p>
))}
</div>
);
};
export default ExampleChild;
Suspense가 data를 fetching하는 component를 바로 wrapping하지 않아도 component 상위에서 Suspense가 적용하고 있다면 component의 loading 상태가 변경될 때 까지 상위 Suspense의 fallback UI가 적용된다. 즉, 아래 예제에선 App.tsx component에서 사용된 Suspense의 fallback UI가 적용된다.
App.tsx
import { Suspense } from "react";
import ExamplePage from "./ExampleChild";
const App = () => {
return (
<div>
<Suspense fallback={<div>app is loading...</div>}>
<ExamplePage />
</Suspense>
</div>
);
};
export default App;
ExamplePage.tsx
import { Suspense } from "react";
import ExampleChild from "./ExampleChild";
const ExamplePage = () => {
return (
<div>
<h3>Example Page</h3>
<Suspense fallback={<div>loading...</div>}>
<ExampleChild />
</Suspense>
</div>
);
};
export default ExamplePage;
ExampleChild.tsx
import { useSuspenseQuery } from "@tanstack/react-query";
import axios from "axios";
const ExampleChild = () => {
const { data:orderList } = useSuspenseQuery({
queryKey: ["orderList"],
queryFn: async () => {
const result = await axios.get(`http://localhost:4000/orders`);
return result.data;
},
});
return (
<div>
{orderList.map((order) => (
<p key={order.id}>{order.name}</p>
))}
</div>
);
};
export default ExampleChild;
여기서 주의할 점은 단순히 data fetch만 수행한다고 해서 Suspense가 fallback UI를 적용하는 것은 아니다. data fetch 과정을 Suspense와 연동하고자 한다면 위의 예제와 같이 react query와 같은 library를 사용하거나 React use API 또는 NextJS와 같은 Suspense-enabled data fetch를 제공하는 framework 또는 React lazy api를 통해 lazy loading하는 component를 loading할 때 Suspense와 연동하여 사용할 수 있다. 예를 들어 다음 예제에서 component는 useEffect에서 data를 fetch하고 있지만 Suspenese의 fallback UI로 대체되지는 않는다.
import axios from "axios";
import { useEffect, useState } from "react";
const ExampleChild = () => {
const [orderList, setOrderList] = useState();
useEffect(() => {
const getOrderList = async () => {
const result = await axios.get("http://localhost:4000/orders");
setOrderList(result.data);
};
getOrderList();
}, []);
return (
<div>
<h3>ExampleChild</h3>
{orderList &&
orderList.map((order: any) => <p key={order.id}>{order.name}</p>)}
</div>
);
};
export default ExampleChild;
만약 여러 개의 component에서 fetch하는 data가 모두 완료된 후에 component를 display하고자 한다면 다음과 같이 여러 component를 하나의 Suspense로 wrapping한다. 아래 예제에선 ExampleOrder component와 ExampleUser component 두 개의 component의 data fetch가 모두 완료될 때 까지 Suspense의 fallback UI가 display된다.
import { Suspense } from "react";
import ExampleOrder from "./ExampleOrder";
import ExampleUser from "./ExampleUser ";
const ExamplePage = () => {
return (
<div>
<h3>Example Page</h3>
<Suspense fallback={<div>loading...</div>}>
<ExampleOrder />
<ExampleUser />
</Suspense>
</div>
);
};
export default ExamplePage;
만약 위의 예제에서 ExampleOrder component에서 fetch하는 data는 3초가 소요되고 ExampleUser component에서 fetch하는 data를 6초가 걸린다면 ExampleOrder component는 3초가 아닌 6초 뒤에 component가 display될 수 있을 것이다. 만약 ExampleOrder component의 data fetch가 완료 되었을 때 ExampleOrder를 우선 display하고 싶다면 ExmapleUser component를 또 다른 Suspense로 wrapping 해준다.
import { Suspense } from "react";
import ExampleOrder from "./ExampleOrder";
import ExampleUser from "./ExampleUser ";
const ExamplePage = () => {
return (
<div>
<h3>Example Page</h3>
<Suspense fallback={<div>loading...</div>}>
<ExampleOrder />
<Suspense fallback={<div>loading...</div>}>
<ExampleUser />
</Suspense>
</Suspense>
</div>
);
};
export default ExamplePage;
위와 같이 처리하면 ExampleUser가 data fetch가 진행중이더라도 ExampleOrder는 먼저 display될 수 있다. 하지만 반대로 위의 예제를 기준으로 ExampleUser component가 data fetch에 3초가 걸리고 ExampleOrder component가 data fetch에 6초가 걸린다면 ExampleUser data fetch가 완료되더라도 ExampleOrder component의 data fetch가 완료될 때 까지 두 component를 wrapping하고 있는 가장 상단의 Suspense의 fallback이 display된다.
Suspends시 기존 data 유지하기
Data fetch시 path, query parameter의 변경으로 새로운 data fetch를 발생시켜야 하면 component는 다시 suspense의 fallback ui로 변경되고 data fetch가 완료 되었을 때 다시 component가 display된다. 만약 Suspense의 fallback을 component를 처음 mount할 때만 사용하고 그 이후 추가적인 data fetch가 발생할 때 data fetch가 완료될 때 까지 Suspense fallback이 아니라 기존 데이터를 그대로 보여주고 싶다면 어떻게 해야 할까?
React Query와 같은 library를 사용하고 있다면 library가 내부적으로 해당 작업을 처리해 주기에 별도의 작업이 필요 없지만 별도의 library를 사용하고 있지 않다면 useDeferredValue나 startTransition을 통해 관련 update를 Transition으로 분류하면 Transition으로 분류된 update를 통해 data fetch가 다시 발생하면 Suspense boundary는 fallback ui를 다시 display하지 않고 background에서 data fetch가 진행되는 동안 이전 data를 통해 render된 component가 그대로 display된다. 아래는 React 공식 documentationd에서 제공하는 예제 코드다. ( Reference - Showing stale content while fresh content is loading )
import { Suspense, useState, useDeferredValue } from 'react';
import SearchResults from './SearchResults.js';
export default function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<>
<label>
Search albums:
<input value={query} onChange={e => setQuery(e.target.value)} />
</label>
<Suspense fallback={<h2>Loading...</h2>}>
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<SearchResults query={deferredQuery} />
</div>
</Suspense>
</>
);
}
위의 예제에서 input state가 upate되면 useDeferredValue hook에 전달하는 값 역시 변경되어 useDeferredValue hook이 return하는 deferredQuery를 통해 SearchResults component는 새로운 data fetch를 발생시킨다.
여기서 deferredQuery와 관련된 render 작업은 Transition으로 분류되므로 Suspense는 component가 처음 mount될 때는 fallback ui를 display하지만 그 이후부터는 deferredQuery의 변경으로 추가적인 data fetch가 발생할 때는 Suspense의 fallback ui가 아니라 data fetch가 완료될 때 까지 기존 data로 render된 component ui를 유지한다.
그렇기에 user에게 현재 추가 data fetch가 진행되고 있는지 알려주기 위해 위의 예제처럼 isState이라는 variable을 추가하여 UI적인 요소를 추가해줄 수도 있다.
이러한 특징을 통해 client-side navigation을 통해 다른 page로 이동할 때 Suspense의 fallback ui를 display하는 것이 아닌 이동한 page load가 모두 완료될 때 까지 현재 페이지 ui를 그대로 보여줄 수 있다. 아래 역시 React documentation에서 제공하는 예제다. ( Reference - Preventing already revealed content from hiding )
import { Suspense, startTransition, useState } from 'react';
import IndexPage from './IndexPage.js';
import ArtistPage from './ArtistPage.js';
import Layout from './Layout.js';
export default function App() {
return (
<Suspense fallback={<BigSpinner />}>
<Router />
</Suspense>
);
}
function Router() {
const [page, setPage] = useState('/');
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
let content;
if (page === '/') {
content = (
<IndexPage navigate={navigate} />
);
} else if (page === '/the-beatles') {
content = (
<ArtistPage
artist={{
id: 'the-beatles',
name: 'The Beatles',
}}
/>
);
}
return (
<Layout>
{content}
</Layout>
);
}
function BigSpinner() {
return <h2>🌀 Loading...</h2>;
}
위의 예제에선 startTransition을 통해 navigation을 위해 사용되는 state update를 Transition으로 구분하므로 Suspense boundary는 처음 mount 될 때는 fallback ui를 display하지만 그 이후 state update 발생을 통해 다른 page를 load할 때 기존 page의 ui를 유지하며 이동하려는 page의 load가 완료되면 해당 page의 ui로 교체한다.
물론 navigation을 위해 third-party library를 사용한다면 위와 같은 작업을 직접 할 필요는 없겠지만 client-side navigaton을 직접 구현한다면 유용하게 활용할 수 있다.