Chapter 09

非同期処理(react-query)

Thirosue
Thirosue
2021.09.08に更新

Webアプリでリッチな機能を実現するためには、サーバ側との連携(REST APIGraphQLなど)は欠かせません。サーバ側との連携は非同期処理が前提となるため、連携前、連携中(ローディング中)、連携後などの状態を管理する必要がありました。

  • ローディング処理を自前で実装する場合のサンプルコード
const processing = false

processing = true // APIコール前に処理中をtrueにする
const response = await window
  .fetch('/api/hoge')
  .then((res) => res.json())
  .finally(() => (processing = false)) // finallyで処理中をfalseにする

また、以下ケースの用に、検索結果などをクライアント側でキャッシュしたい場合など、

  • アイテム詳細画面からすぐにアイテム検索結果一覧画面に戻る場合
  • アイテム一覧画面のページリンクで同一ページを短期間に行き来した場合

独自にキャッシュの仕組みを実装する(ex: セッションストレージにキャッシュを保管しておき、有効期限内の場合、キャッシュを参照するなど)とアプリの複雑性は増していきます。

React Queryを用いると、これらの課題に省力で容易に対応できます。

https://react-query.tanstack.com/

利用設定

非同期処理は広範囲な画面で利用するため、レイアウトコンポーネントにReact Queryを利用する設定をしておき、各画面では意識せずReact Queryを利用できるようにします。

components/template/dashboard-layout.tsx
import { QueryClient, QueryClientProvider } from 'react-query'

...(中略)...

const queryClient = new QueryClient()

export const DashboardLayout = ({
  children,
  title,
}: {
  children: React.ReactNode
  title: string
}): JSX.Element => {

  return (
    <>
      <QueryClientProvider client={queryClient}> <!-- ReactQureyClient利用プロバイダーでラップする --->
        <ConfirmProvider>
          <GlobalStateProvider>
            <Seo title={title} />
            <div className="flex h-screen bg-gray-200 font-roboto">
              <SideBar
                sidebarOpen={sidebarOpen}
                toggle={() => setSidebarOpen(false)}
              />
              <div className="flex-1 flex flex-col overflow-hidden">
                <Header toggle={() => setSidebarOpen(true)} />
                <main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-200">
                  {children}
                </main>
              </div>
            </div>
          </GlobalStateProvider>
          <ReactQueryDevtools initialIsOpen={false} /> <!-- 開発時は、DevToolを利用する --->
        </ConfirmProvider>
      </QueryClientProvider>
      <ToastContainer
        autoClose={3000}
        hideProgressBar={false}
        newestOnTop={false}
        closeOnClick
        rtl={false}
        position={'bottom-right'}
        pauseOnFocusLoss
        draggable
        pauseOnHover
      />
    </>
  )
}

参照処理

React Queryでは、参照処理をuseQueryフックを利用して処理します。
以下サンプルでは、商品一覧の取得処理を例に扱います。

  • Queryガイド

https://react-query.tanstack.com/guides/queries

商品一覧は以下フローで更新を行います。

  1. 検索アクション実行(検索キーワード入力、ページ変更など)
  2. push.stateし、URLを書き換える
  3. URL変更を検出し、クエリキーとなるオブジェクト(キーワード、ページング、ソート)を変更する
  4. クエリが再発行され、商品一覧が更新される

処理のポイントは以下サンプルコードのコメントに記載しています。

components/page/index-page.tsx
import { useQuery } from 'react-query'

// 画面で想定するURLパラメータを宣言
type IndexQuery = ParsedUrlQuery & {
  keyword: string
  page: string
}

export const IndexPage = (): JSX.Element => {

  /*
  * 商品一覧取得処理をuseQueryを用いて定義
  * 
  * products.isLoadingで商品一覧取得処理中の状態を参照できる
  * products.isFetchedで商品一覧処理取得完了の状態を参照できる
  */ 
  const products = useQuery(
  
    /*
    * クエリキー(文字列)と入力パラメータの組み合わせでキャッシュを制御する
    * パラメータが変更されるごとに商品一覧を更新する
    *
    * キーワード、ページングオブジェクト、ソートオブジェクトをクエリのキーに設定し、
    * ボタンアクションなどに応じて、クエリキーを変化させることでクエリ発行を行い、商品一覧を再表示する
    */ 
    ['products', [keyword, pageItem, sortItem]], 
    (): AxiosPromise<ProductResponse> =>
      ProductRepository.findAll({
        name: keyword,
        page: pageItem.page - 1,
        order: sortItem.order,
        orderBy: sortItem.key,
        rows: Const.defaultPageValue.perPage,
      })
  )

  /*
  * クエリパラメータが変化するごとにクエリキーを変更し、結果、クエリ発行を行う
  */ 
  useEffect(() => {
    const { keyword, page } = router.query as IndexQuery
    setKeyword(keyword)
    setPageItem({
      ...pageItem,
      page: page ? Number(page) : 1,
    })
  }, [router.query])

...(中略)...

  /*
  * 以下の流れで商品一覧を更新する
  * 
  * 1. 検索アクション(検索キーワード入力し、検索)
  * 2. push.stateし、URLを書き換える
  * 3. URL変更を検出し、クエリキーオブジェクト(キーワード、ページング、ソート)を変更する
  * 4. クエリが再発行され、商品一覧が更新される
  */ 
  const pushState = async (page: number, sort?: SortItem): Promise<void> => {
    await router.push({
      query: {
        keyword,
        page,
        orderBy: sort?.key ?? sortItem.key,
        order: sort?.order ?? sortItem.order,
      },
    })
  }

  return (
    <>
      {/*  コンテンツ */}
      <Progress processing={products.isLoading} />  <!-- 更新中にはローディングアニメーションを表示する -->
      <div className="container mx-auto px-6 py-8">
        <h3 className="text-gray-700 text-3xl font-medium">Dashboard</h3>

        <div className="mt-4">
          <Dashboard />
        </div>

        <div className="mt-8" />

        <SearchableTable
          search={pushState}
          headerItems={headerItems}
          pageItem={pageItem}
          sortItem={sortItem}
          setSortItem={setSortItem}
          queryResult={products}
        >
          <tbody className="bg-white">
	    <!-- データ取得が完了したら、取得したデータを商品一覧へ展開する -->
            {products.isFetched &&
              products.data.data.data.map((product: Product, index: number) => (
                <ProductRow key={index} product={product} />
              ))}
          </tbody>
        </SearchableTable>
      </div>
    </>
  )
}

更新処理

React Queryはミューテーションと呼ばれる更新機能を、useMutationフックとして提供しています。

  • Mutationガイド

https://react-query.tanstack.com/guides/mutations

以下サンプルでは、商品情報の更新処理を例に扱います。

pages/product/[id].tsx
import { useMutation } from 'react-query'

export default function ProductDetail({
  product,
}: {
  product: Product
}): JSX.Element {
  // useMutationを用いて商品更新処理を宣言
  // mutation.isLoadingで更新処理中の状態を参照できる
  const mutation = useMutation(
    (req: ProductUpdateRequest): AxiosPromise<BaseResponse> =>
      ProductRepository.update(req)
  )

  const doSubmit = (data: Product): void => {
    captains.log(data)
    const request: ProductUpdateRequest = { ...data } // フォームの状態をリクエストに詰める
    // mutateで更新処理を実施
    mutation.mutate(request, {
     // onSuccessで成功時の処理を定義、onErrorでエラー時の処理を定義する
      onSuccess: async () => {
        await router.push(`/complete?to=/`)
        setTimeout(() => toast.success('商品を更新しました'), 100) // display toast after screen transition
      },
    })
  }

  return (
    <>
      <Progress processing={mutation.isLoading} /> <!-- 更新中にはローディングアニメーションを表示する -->
      <div className="container mx-auto px-6 py-8">
        <Typography variant="h4">商品詳細</Typography>

        <form className="mt-4" onSubmit={handleSubmit(doSubmit)}> <!-- フォームサブミット処理 -->
          <label className="block">
            <FormLabel>ID</FormLabel>
            <InputLabel fullWidth={true} value={product.id} />
          </label>

          <label className="block mt-3">
            <FormLabel>Name</FormLabel>
            <input
              id="name"
              type={TextFieldType.Text}
              className={`mt-1 w-full border-gray-300 block rounded-md focus:border-indigo-600 ${
                errors.name ? 'border-red-400' : ''
              }`}
              {...register('name')}
            />
            <FormErrorMessage>{errors.name?.message}</FormErrorMessage>
          </label>

...(中略)...

          <div className="mt-6 flex justify-center">
            <Button onClick={back} color={'default'} classes={['mx-4']}>
              戻る
            </Button>
	    <!-- 更新中のサーバへの再送信をdisabledで防止する -->
            <Button disabled={mutation.isLoading} color={'primary'}>
              更新
            </Button>
          </div>
        </form>
      </div>
    </>
  )
}

参考リンク

https://fintan.jp/?p=5583

https://note.yuuniworks.com/study/react-query.html#原典