カテゴリー: FrontEnd

React QueryのSuspese Modeを使ってみた! [TypeScript]

はじめに

React 18が先日正式リリースされましたね🎉 その中でも、Suspense機能が気になったので調べていたところ、React QueryがSuspenseモードをサポートしていたため、調査して記事にまとめました。

React 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

今回は、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 }
  );
};

Suspenseモードの有効化

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モードを有効化すると、 isLoadingerrorの面倒を見る必要がなくなります。その代わりにローディング中は、Promiseがthrowされるようになるため、このIssuesコンポーネントをサスペンド状態となり、レンダリングされなくなります。
エラーが発生した場合は、親コンポーネントにエラーがthrowされるため、ErrorBoundaryでキャッチする必要があります。

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の実装

ここからは、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(クエリを実行するコンポーネント) という親子関係になっている点がポイントです。これらのコンポーネントが実際に動作する流れを説明します。

  • Issuesコンポーネントがローディング中の場合、サスペンドするため、そのfallbackである <h1>loading...</h1>が表示されます。
  • ローディングが完了すると、Issuesコンポーネントがレンダリングされます。
  • エラーが発生した場合は、ErrorBoundaryコンポーネントでキャッチします。
  • QueryErrorResetBoundaryを使うと、エラー発生時に 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モードの方が効率よく実装できるかもしれませんね!

おすすめ書籍

カイザー

シェア
執筆者:
カイザー

最近の投稿

フロントエンドで動画デコレーション&レンダリング

はじめに 今回は、以下のように…

3週間 前

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

1か月 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前

FSMを使った状態管理をGoで実装する

はじめに 一般的なアプリケーシ…

3か月 前