これは GLOBIS Advent Calendar 2023 10日目の記事です。
はじめに
こんにちは。GLOBIS学び放題でWebエンジニアをしている富山です。
GLOBIS学び放題のWebフロントエンドの構成は、React(TypeScript) / GraphQLで、GraphQLのクライアントライブラリには、Apollo Client + GraphQL Code Generatorを利用しています。
今回は、Apollo Clientのバージョンアップにより、Suspenseが利用可能になったので、本記事では、実際に学び放題のコードベースに適応しようとするとどうなるか検証してみました。
Apollo Client v3.8からSuspense対応された
Suspense自体はReact18でリリースされたもので、Reactを利用してる方ならお馴染みの機能ですが、Relayやurql等、他のGraphQL APIのクライアントライブラリがSuspenseに対応してる中、Apollo Clientはやや周回遅れ気味でしたが、遂にApollo Clientもv3.8からSuspense対応されました!
Suspenseを導入するにあたって
ざっくりSuspenseについておさらいです。
Suspenseは、非同期処理を管理する機能で、対象のコンポーネントがフェッチデータを持っていることを伝えるための仕組みになります。
データフェッチ完了後にレンダリングしたいコンポーネントは Suspenseでwrapすると、小要素が読み込みを完了するまで、最も近いSuspenseからfallbackに渡したReactNodeを表示し、完了すると即座にレンダリングを開始します。
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
Apollo Client環境にSuspenseを導入する
Apollo Clientが使われてるプロジェクトでは、以下のような処理を見たり、書いたりしたことがあると思います。
data型は T | undefined なので値のチェックをしてから使用します。
学び放題のデータフェッチは、各ページのcontainer層でデータをfetchして、pages層 → component層に流していくので、各ページのcontainerには以下のような定型コードがあります。useQuery
から返ってくるloading
とerror
で制御しており、dataはundefined
にもなり得るので、値のチェックもします。
export const SampleContainer = () => {
const {
data: samplePageData,
loading: samplePageLoading,
error: samplePageError,
} = useSamplePageQuery()
if (samplePageError) {
throw samplePageError
}
if (!sampleData || samplePageLoading) {
return <LoadingScreen />
}
}
Apollo Client3.8以降では、useSuspenseQuery
というhooksが追加されています。
このhooksは、文字通りuseQuery
のSuspense対応版で、リクエストが行われている間は、呼び出し元のコンポーネントをsuspendしてくれます。
以下、サンプルのコードですが、loading
とerror
の状態を外で管理できるようになりました。
これで、dataの型が T | undefinedでなくなるので、dataのundefinedのチェックが不要になります。
また、エラーハンドリングは、これまで、useQuery
の返り値を使用していましたが、ErrorBoundaryでラップして制御する形が推奨されています。(useSuspenseQuery
にerrorPolicy
を指定することで、これまで通りにerror
の返り値を使った実装も可能)
コンポーネントの内部にあったloading
とerror
の状態が外部で扱えるようになり、これまでloading
やerror
の状態を見ていたファイルは、dataのみを扱えば良くなるので、見通しが良くなります。
export const SuspenseSampleContainer = () => {
const { data: sampleSuspensePageData } = useSampleSuspensePageSuspenseQuery()
return <SuspenseSamplePage {...sampleSuspensePageData} />
}
export const AppRoute: React.FC<Props> = () => {
return (
<Route
path="/sample"
element={
<ErrorBoundary fallback={<>Something went wrong</>}>
<Suspense fallback="loading...">
<SuspenseSampleContainer />
</Suspense>
</ErrorBoundary>
}
/>
)
}
OutletとSuspenseを併用する
また、OutletとSuspenseを組み合わせた利用も相性が良さそうです。
Globis学び放題では、React-Router v6から利用できるようになったOutletを活用してレイアウトを共有しながら、中身の各ページのみをルーティングできるようにしています。Outletのあるコンポーネントを親のRouteに設定すると、子のRouteに定義しているコンポーネントは、Outletを置き換える形で表示されます。OutletとSuspsenを組み合わせることで、よりスッキリ書けるようになります。
const MainLayoutComponent: React.FC<MainLayoutProps> = ({
header,
footer = {},
className,
}) => {
return (
<div>
<Header {...header} />
<main className={`${className}__main`}>
<Outlet />
</main>
<Footer {...footer} />
</div>
)
}
<Route
element={
<MainLayout />
}
>
<Route
path="/sample"
element={
<Suspense fallback="loading...">
<SuspenseSampleContainer />
</Suspense>
}
/>
<Route
path="/sample2"
element={
<Suspense fallback="loading">
<SuspenseSampleContainer2 />
</Suspense>
}
/>
</Route>
OutletをSuspnseで囲むことで、fallback
の指定をLayout1カ所で管理することも可能になります。
レイアウトを共有しながら、小要素であるcontainer部分だけ、loaderを表示させることが可能です。また、lazyを利用したコード分割時にも活きてきそうです。
const MainLayoutComponent: React.FC<MainLayoutProps> = ({
header,
footer = {},
className,
}) => {
return (
<div>
<Header {...header} />
<main className={`${className}__main`}>
<Suspense fallback="loading...">
<Outlet />
</Suspense>
</main>
<Footer {...footer} />
</div>
)
}
<Route element={<MainLayout />}>
<Route path="/sample" element={<SuspenseSampleContainer />} />
<Route path="/sample2" element={<SuspenseSampleContainer2 />} />
</Route>
最後に
Apollo Clientv3.8以降では、Suspenseの他にもFragment Colocationの導入もしやすくなっていたりと、色々と進化してる部分があり、ドキュメントを読むのも楽しかったです。
グロービスの開発組織では、一緒にモノづくりに取り組んでくれる仲間を募集しています!興味がある方はぜひご連絡ください!!