React 18が先日正式リリースされましたね🎉 その中でも、Suspense機能が気になったので調べていたところ、React QueryがSuspenseモードをサポートしていたため、調査して記事にまとめました。
Suspenseとは、データ取得するときのローディング状態を表現できるコンポーネントで、次のような使い方をします。
<Suspense fallback{<h1>Loading...</h1>}> <Issues /> </Suspense>
Issuesはクエリを実行するコンポーネントですが、ローディング中はPromiseがthrowされることによってサスペンド状態となり、代わりにfallbackに指定した内容がレンダリングされる仕組みです。
コンポーネントがサスペンド状態になると、レンダリング自体が行われなくなるため、レンダリングコストの軽減にもつながりそうですね。
ちなみに、Suspense自体はReact 16.6で実験的機能として追加され、React Queryも以前からサポートされていたようですが、React 18で正式版となったことで、使いたい場面が増えてくるかもしれません!
React Queryでは、Suspenseモードを有効化することで、実装できるようになります。Suspenseモードを使わない場合では、useQuery() から data, isLoading, error を取得し、isLoadingやerrorの内容をみてコンポーネントの中身を出し分けるという実装が多かったと思います。
Suspenseモードを使う場合は、ローディング中はPromiseがthrowされ、エラーの場合はJSエラーがthrowされるため、これらの状態によってコンポーネントの中身を出し分ける必要がなくなります。
その代わりに、親コンポーネントでのハンドリングが必要となり、ローディング中はSuspenseコンポーネント、エラーはErrorBoundaryを使って実装します。
それでは、ここからは実装方法を紹介します。
今回は、React QueryのSuspense Modeを使った実装方法を紹介します。全体のコードはこちらのCodeSandBoxで動かせるようになっています!
React Queryのhookを実装します。今回は、Github APIのIssuesを取得するAPIをリクエストするようにしています。
import { useQuery } from "react-query"; import axios from "axios"; export interface Issue { id: number; number: number; url: string; repository_url: string; title: string; state: string; user: User; } export interface User { login: string; avatar_url: string; } export const useIssueQuery = (labels: string) => { return useQuery( ["issues", labels], async () => { const { data } = await axios.get<Issue[]>( "https://api.github.com/repos/octocat/hello-world/issues", { params: { labels }, } ); return data; }, { staleTime: 30000 } ); };
React Queryでは、hook単位でSuepenseモードを有効化する方法と、全体で有効化する方法があります。今回は全体で有効化する方法で実装してみます。
QueryClientの生成時のオプションとして、 suspense: true
を設定することでSuspenseモードを有効化できます。
import React from "react"; import "./index.css"; import App from "./App"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "react-query"; import { ReactQueryDevtools } from "react-query/devtools"; import { createRoot } from "react-dom/client"; const queryClient = new QueryClient({ defaultOptions: { queries: { suspense: true, // ここでsuspenseモードを全体で有効化する }, }, }); const container = document.getElementById("root"); if (!container) throw new Error("Failed to find the root element"); const root = createRoot(container); root.render( <React.StrictMode> <BrowserRouter> <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> </BrowserRouter> </React.StrictMode> );
GithubのIssueを取得して表示するコンポーネントを実装します。
interface IssuesProps { labels: string; } const Issues: VFC<IssuesProps> = ({ labels }) => { const { data } = useIssueQuery(labels); return ( <Paper> {data?.length ? ( <Table sx={{ minWidth: 650 }} aria-label="simple table"> <TableHead> <TableRow> <TableCell>No.</TableCell> <TableCell>Title</TableCell> <TableCell>Status</TableCell> <TableCell>User</TableCell> </TableRow> </TableHead> <TableBody> {data?.map((row) => ( <TableRow key={row.id} sx={{ "&:last-child td, &:last-child th": { border: 0 } }} > <TableCell component="th" scope="row"> {row.number} </TableCell> <TableCell>{row.title}</TableCell> <TableCell>{row.state}</TableCell> <TableCell> <Avatar src={row.user.avatar_url} /> {row.user.login} </TableCell> </TableRow> ))} </TableBody> </Table> ) : ( <Typography> <ErrorOutline /> There are no issues found </Typography> )} </Paper> ); };
この実装のポイントとしてはReact QueryでSuspenseモードを有効化すると、 isLoading
や error
の面倒を見る必要がなくなります。その代わりにローディング中は、Promiseがthrowされるようになるため、このIssuesコンポーネントをサスペンド状態となり、レンダリングされなくなります。
エラーが発生した場合は、親コンポーネントにエラーがthrowされるため、ErrorBoundaryでキャッチする必要があります。
続いて、ErrorBoundaryを実装します。
interface ErrorBoundaryProps { children: ReactNode; onReset: () => void; } interface State { hasError: boolean; } class ErrorBoundary extends Component<ErrorBoundaryProps, State> { public state: State = { hasError: false, }; public static getDerivedStateFromError(_: Error): State { // Update state so the next render will show the fallback UI. return { hasError: true }; } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { console.error("Uncaught error:", error, errorInfo); } public render() { console.log(this.props.onReset); if (this.state.hasError) { return ( <> <h1>Error: Failed to fetch data.</h1> <Button onClick={() => { this.setState({ hasError: false }); this.props.onReset(); }} > Retry </Button> </> ); } return this.props.children; } }
このErrorBoundaryは、エラーが発生したときに、クエリを再試行する仕組みを実装しました。この仕組みを紹介します。
Propsで reset: () => void
を受け取るようにしています。これは、後ほど実装する親コンポーネントの QueryErrorResetBoundary
から受け取れる関数がそのまま入るようになっいます。
この reset()
関数は、子コンポーネントで実行された直近のクエリをリセットし、再試行させることができます。
ここからは、Suspenseコンポーネントを使って実装します。
import { Component, ErrorInfo, ReactNode, Suspense, useState, VFC, } from "react"; import { Avatar, Box, Button, Container, Paper, Skeleton, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, } from "@mui/material"; import { Issue, useIssueQuery } from "./github-hooks"; import { QueryErrorResetBoundary } from "react-query"; import { ErrorOutline } from "@mui/icons-material"; export const IssuesContainer: VFC = () => { return ( <Container> <QueryErrorResetBoundary> {({ reset }) => ( <ErrorBoundary reset={reset}> <Suspense fallback={<h1>Loading...</h1>} > <Issues labels="" /> </Suspense> </ErrorBoundary> )} </QueryErrorResetBoundary> </Container> ); };
親から順に QueryErrorResetBoundary > ErrorBoundary > Suspense > Issues(クエリを実行するコンポーネント) という親子関係になっている点がポイントです。これらのコンポーネントが実際に動作する流れを説明します。
<h1>loading...</h1>
が表示されます。reset()
を呼び出すことで、クエリを再試行できます。せっかくローディング状態を実装するので、MUIのSkeltonを使ったUIも作ってみました。
import { Component, ErrorInfo, ReactNode, Suspense, useState, VFC, } from "react"; import { Avatar, Box, Button, Container, Paper, Skeleton, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, } from "@mui/material"; import { Issue, useIssueQuery } from "./github-hooks"; import { QueryErrorResetBoundary } from "react-query"; import { ErrorOutline } from "@mui/icons-material"; export const IssuesContainer: VFC = () => { const [labelsInput, setLabelsInput] = useState(""); const [labels, setLabels] = useState(""); return ( <Container> <TextField value={labelsInput} label="Labels" onChange={(e) => setLabelsInput(e.target.value)} /> <Button onClick={() => setLabels(labelsInput)}>Search</Button> <QueryErrorResetBoundary> {({ reset }) => ( <ErrorBoundary error={error}> <Suspense fallback={ <TableContainer component={Paper}> <Table sx={{ minWidth: 650 }} aria-label="simple table"> <TableHead> <TableRow> <TableCell>No.</TableCell> <TableCell>Title</TableCell> <TableCell>Status</TableCell> <TableCell>User</TableCell> </TableRow> </TableHead> <TableBody> {[...Array(5)].map((_, i) => { return ( <TableRow key={i} sx={{ "&:last-child td, &:last-child th": { border: 0 }, }} > <TableCell component="th" scope="row"> <Skeleton variant="text" /> </TableCell> <TableCell> <Skeleton variant="text" /> </TableCell> <TableCell> <Skeleton variant="text" /> </TableCell> <TableCell> <Skeleton variant="circular" width={40} height={40} /> <Skeleton variant="text" /> </TableCell> </TableRow> ); })} </TableBody> </Table> </TableContainer> } > <Issues labels={labels} /> </Suspense> </ErrorBoundary> )} </QueryErrorResetBoundary> </Container> ); };
React QueryのSuspenseモードを使うと、これまでは各コンポーネントでハンドリングしていた状態を、親コンポーネントに集約することができ、実装がスッキリすることが分かりました。各コンポーネントで同じようなハンドリングを実装している場合は、Suspenseモードの方が効率よく実装できるかもしれませんね!