はじめに

本ドキュメントは、 React Queryを用いた開発事例の紹介です。 React Queryは、Reactで非同期データをフェッチ、キャッシング、更新するためのフックを提供するライブラリです。 React Queryを用いることで、効率的なバックエンド連携が実現できます。 本ドキュメントでは、このライブラリをどのように開発に適用したかを、ライブラリの機能説明を交えながら紹介します。 また、React Queryの特徴を活かしたシステム改善案についても紹介します。

想定読者

バックエンドと連携するシングルページアプリケーションをReactで開発するエンジニア。

参考文献・URL

背景

昨今、フロントエンドをシングルページアプリケーション(以下、SPA)として構築するケースが増えてきました。 こうした背景には、UXの向上や多様なUIデバイスへの対応があります。 最近では、React、Angular、Vue.jsといったSPA開発をサポートするフレームワークの機能も充実し、効率的にフロントエンドを開発が出来るようになりました。 とはいえ、今まで主流であったサーバサイド主体のWeb開発とは異なる、SPA開発ならではの課題もあります。 その中の1つとして、フロントエンドの状態管理の難しさが挙げられます。 特にバックエンド連携のような非同期処理においては、いくつもの状態を考慮しなければなりません。

  • 連携前
  • 連携中(ローディング中)
  • 連携後(正常終了またはエラー)

これらに加え、次のような考慮が加わると、複雑性はさらに増加します。

  • 連携タイミング(検索条件の変更・次/前ページングへの遷移・ポーリング)
  • キャッシュ
  • エラーハンドリングおよびエラーリトライ

今回紹介する開発事例は、Reactを用いたSPA開発です。 上記課題への対応として、React Queryを用いました。 React Queryは、このような課題に対するソリューションを提供するライブラリです。

開発事例となるシステムの特徴は次の通りです。

  • システム運営者が用いる管理系システム
  • ユーザーアカウントの登録承認・拒否やアカウント停止、ユーザー動向の監視などの機能を提供
  • 一覧・単票形式の一般的な画面構成
  • 一覧画面では検索・ソート条件の指定、ページング機能がある

React Queryを用いたバックエンド連携

このライブラリをどのように開発に適用したかを紹介するにあたり、開発事例システムの特徴を元にしたデモアプリを用います。 デモアプリの構成は次のとおりです。

  • バックエンドはSpring Boot
  • フロントエンドはReact
  • バックエンド連携にはaxios(PromiseベースのHTTPクライアント)を使用

このデモアプリは、アカウント一覧の表示から、アカウントの有効/無効切替を提供します。

では、ここからはデモアプリのソースコードをもとに、具体的にReact Queryの機能を紹介していきます。 まずは、背景となるソースコードを紹介をします。 次のJavaソースコードは、DTO(データトランスファオブジェクト)となるアカウント情報です。

/** アカウント情報 */ 
public class Account { 
    private long id; 
    /** 名前 */ 
    private String name; 
    /** 有効の場合は true */ 
    private boolean enabled; 
    /* getter/setterは省略 */ 
}

次に、RESTful APIを提供するコントローラのJavaソースコードです。 アカウント一覧の返却、アカウントの有効/無効切替を提供します。 アカウント一覧は、Spring Dataより提供されている機能を用いて、ページング機能を実現しております。 本ドキュメントの趣旨から外れますので、実装部分は省略しております。

  @RestController
  @RequestMapping("api")
  public class AccountController {
  
      /** アカウント一覧の取得 */
      @GetMapping("/account")
      public Page<Account> getAccounts(Pageable pageable) {
          /* 省略 */
      }
  
      /** アカウントの無効化 */
      @PutMapping("/account/{id}/disable")
      public void disableAccount(@PathVariable("id") int id) {
          /* 省略 */
      }
  
      /** アカウントの有効化 */
      @PutMapping("/account/{id}/enable")
      public void enableAccount(@PathVariable("id") int id) {
          /* 省略 */
      }
  }

バックエンド連携するフロントエンドのTypescriptソースコードは次の通りです。 開発事例のシステムと同様に、axiosを用いて連携しています。

  import axios, { AxiosPromise } from 'axios';
  
  /** ページ指定 */
  export interface Pageable {
      page?: number;
      size?: number;
      sort?: string;
  }
  
  /** ソート指定 */
  export interface Sort {
      empty?: boolean;
      sorted?: boolean;
      unsorted?: boolean;
  }
  
  /** アカウント情報 */
  export interface Account {
      id: number;
      /** 名前 */
      name: string;
      /** 有効の場合は true */
      enabled: boolean;
  }
  
  /** アカウント情報のページ */
  export interface PageAccount {
      /** ページのアカウント一覧 */
      content?: Array<Account>;
      /** ページが0件の場合は true */
      empty?: boolean;
      /** 最初のページの場合は true */
      first?: boolean;
      /** 最後のページの場合は true */
      last?: boolean;
      /** 何ページ目か */
      number?: number;
      /** ページに含まれる要素の件数 */
      numberOfElements?: number;
      /** ページ指定 */
      pageable?: Pageable;
      /** ページのサイズ(要素の最大件数) */
      size?: number;
      /** ソート指定 */
      sort?: Sort;
      /** 全要素数 */
      totalElements?: number;
      /** 総ページ数 */
      totalPages?: number;
  }
  
  export class AccountController {
  
      /** アカウント一覧の取得 */
      getAccounts(page?: number, size?: number, sort?: string): AxiosPromise<PageAccount> {
          const params = {} as any;
          if (page !== undefined) {
              params['page'] = page;
          }
          if (size !== undefined) {
              params['size'] = size;
          }
          if (sort !== undefined) {
              params['sort'] = sort;
          }
          return axios.get(`/api/account`, { params });
      }
  
      /** アカウントの無効化 */
      disableAccount(id: number): AxiosPromise<void> {
          return axios.put(`/api/account/${id}/disable`)
      }
  
      /** アカウントの有効化 */
      enableAccount(id: number): AxiosPromise<void> {
          return axios.put(`/api/account/${id}/enable`)
      }
  }

背景となるソースコードの紹介はこれで以上です。

ここからは、本題のReact Queryについて紹介します。 次のTypescriptコードは、React Queryを用いてバックエンド連携している個所の抜粋です。

  import { useQuery } from "react-query";
  
  // アカウント一覧取得
  const { isLoading, isError, data: response, error } = useQuery(
    "accounts",
    () => new AccountController().getAccounts(0, 10)
  );

useQueryフックを呼び出すことで、バックエンドと連携します。 このフックを呼び出すにあたり、次の2つの引数を渡します。

  • クエリーキー(ユニークなキー)
  • データ解決する非同期関数

ここでは、クエリーキーとして"accounts"という文字列を渡しています。 クエリーキーは、React Queryのコアとなるものです。 React Queryはクエリーキーに基づき、クエリー結果をキャッシュ管理します。 クエリーキーには、文字列のようなシンプルなものから、配列や値、ネストしたオブジェクトのような複雑なものまで指定可能です。 シリアライズ可能で、クエリーのデータに対して一意である必要があります。

非同期関数として次のアロー関数を指定しています。

() => new AccountController().getAccounts(0, 10)

こちらは、0ページ目から10件のアカウント情報をバックエンドから取得します。

useQueryフックの戻り値として、次のような(これが全数ではありません)情報が返ります。

  • isLoading: Boolean
    • ローディング中(キャッシュされたデータがなく現在フェッチ中)
  • isError: Boolean
    • クエリーの試行結果がエラーとなった場合
  • data: Any
    • 最後に解決したクエリー結果
    • デフォルトはundefined
  • error: null | Error
    • 非同期関数からスローされたエラー内容
    • デフォルトはnull

useQueryフックの戻り値をうけて、レンダリング処理を簡潔に定義したソースコード個所が次の通りです。

    if (isLoading) {
      // ローディング中
      return (
        <div className="app-loading">
          <span>Loading...</span>
        </div>
      );
    }
  
    if (isError) {
      // エラー発生
      return <span>Error: {error?.message}</span>;
    }
  
    return (
          /* 省略 */
    );

上記コードのとおり、ローディング中はLoading... の文字を、エラー発生時はエラー内容を画面に表示します。 このように、それぞれの状態に応じたレンダリング処理を簡潔に定義できます。

上記内容を組み込んだ、アカウント一覧画面の全ソースコードがこちらです。

  import React from "react";
  import { useQuery } from "react-query";
  import { AccountController } from "./api/api";
  import "./App.css";
  
  function App() {
    // アカウント一覧取得
    const { isLoading, isError, data: response, error } = useQuery(
      "accounts",
      () => new AccountController().getAccounts(0, 10)
    );
    
    if (isLoading) {
      // ローディング中
      return (
        <div className="app-loading">
          <span>Loading...</span>
        </div>
      );
    }
  
    if (isError) {
      // エラー発生
      return <span>Error: {error?.message}</span>;
    }
  
    return (
      <div className="columns">
        <div className="column is-1"></div>
        <div className="column">
          <h1 className="title">Demo</h1>
          <table className="table is-fullwidth">
            <thead>
              <th>id</th>
              <th>name</th>
              <th>enabled</th>
            </thead>
            <tbody>
              {response!.data.content?.map((account) => (
                <tr key={account.id}>
                  <th>{account.id}</th>
                  <td>{account.name}</td>
                  <td>{account.enabled ? "有効" : "停止"}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
        <div className="column is-1"></div>
      </div>
    );
  }
  
  export default App;

このコードを実行すると、次の画面が表示されます※1

(※1) 画面の見栄えを整える目的で(開発事例のシステムでも導入した)Bulma CSSフレームワークをデモアプリに適用しています。

説明を分かりやすくするために、上記コードではアカウント一覧に必要な様々な機能を省いています。 これからこの画面に機能を追加していきながら、順を追って機能を紹介していきます。

まずはページング(次・前へ)とソート条件の指定を追加します。 次・前へボタンをクリックしたり、IDの右にある矢印アイコンをクリックすることで、ページが切替わったりソートします。 これを実現するのが次のコード部分です。

  const { isLoading, isError, data: response, error } = useQuery(
    ["accounts", { page, idSort }],
    (key, { page, idSort }) =>
      new AccountController().getAccounts(page, 10, `id,${idSort}`)
  );

page変数で検索するページ番号と、idSort変数でソート条件をクエリーキーに(配列形式で)渡します。 これにより、クエリー結果はページ番号とソート条件毎にキャッシュされます。 クエリーキーが変化すると、クエリーは再度実行されます。 また、クエリーキーに渡した内容は、非同期関数の引数として受け取ることができます。 これにより、ページ番号とソート条件に応じたアカウント情報をバックエンドから取得します。

修正したアカウント一覧画面のコードは次のとおりです。

  import React, { useState } from "react";
  import { useQuery } from "react-query";
  import { AccountController } from "./api/api";
  import "./App.css";
  
  function App() {
    const [page, setPage] = useState(0);
    const [idSort, setIdSort] = useState<"ASC" | "DESC">("ASC");
    // アカウント一覧取得
    const { isLoading, isError, data: response, error } = useQuery(
      ["accounts", { page, idSort }],
      (key, { page, idSort }) =>
        new AccountController().getAccounts(page, 10, `id,${idSort}`)
    );
  
    if (isLoading) {
      // ローディング中
      return (
        <div className="app-loading">
          <span>Loading...</span>
        </div>
      );
    }
  
    if (isError) {
      // エラー発生
      return <span>Error: {error?.message}</span>;
    }
  
    return (
      <div className="columns">
        <div className="column is-1"></div>
        <div className="column">
          <h1 className="title">Demo</h1>
          <table className="table is-fullwidth">
            <thead>
              <th>
                id
                <span
                  className="app-is-clickable"
                  onClick={() => {
                    setIdSort(idSort === "ASC" ? "DESC" : "ASC");
                  }}
                >
                  {idSort === "ASC" ? " ↓ " : " ↑ "}
                </span>
              </th>
              <th>name</th>
              <th>enabled</th>
            </thead>
            <tbody>
              {response!.data.content?.map((account) => (
                <tr key={account.id}>
                  <th>{account.id}</th>
                  <td>{account.name}</td>
                  <td>{account.enabled ? "有効" : "停止"}</td>
                </tr>
              ))}
            </tbody>
          </table>
  
          {/* navigation */}
          <nav className="pagination">
            {!response?.data.first && (
              <a
                href="/#"
                className="pagination-previous"
                onClick={() => setPage(page - 1)}
              >
                前へ
              </a>
            )}
            {!response?.data.last && (
              <a
                href="/#"
                className="pagination-next"
                onClick={() => setPage(page + 1)}
              >
                次へ
              </a>
            )}
            <ul className="pagination-list"></ul>
          </nav>
        </div>
        <div className="column is-1"></div>
      </div>
    );
  }
  
  export default App;

このコードを実行すると、次の画面が表示されます。

一見、シンプルな動作に見えますが、React Queryは裏(デフォルトの設定)で様々なことをしています。

  1. 画面に表示されたクエリー結果はすぐに “古い状態”となり、再レンダリングされた時、バックグラウンドで自動的にリフェッチされます
  2. 未使用となった(すべてのクエリーがインスタンスからアンマウントされた)クエリー結果は、キャッシュ有効期限切れ(デフォルト5分)になるまで再利用できます
  3. 古くなったクエリー結果は、ブラウザウィンドウへの再フォーカス時、バックグラウンドで自動的にリフェッチされます
  4. クエリーの実行でエラーが発生した場合、バックグラウンドでリトライします。リトライは3回行われ、リトライ間隔は指数関数的に遅延します
  5. クエリー結果とキャッシュ内容を深く比較し※2、変更のないデータ参照は置き換えません
(※2) 深い比較(deep compare)は、Objectの一段目までを比較する浅い比較(shallow compare)とは異なり、深いObject階層まで値の一致を比較する。 React Queryの utils.js に定義している deepEqual 関数で実現している。

これらの設定はクエリー毎、または(後で紹介する)グローバルな方法で変更することが出来ます。

これまでの説明にあるとおり、React Queryはクエリーキーに基づきクエリー結果をキャッシュします。 キャッシュされたデータは画面レンダリングする際に用いられ、再レンダリング時に古いクエリー結果(デフォルトではすぐに古くなります)はバックグラウンドで自動的にリフェッチされます。 つまり、既に取得したことのあるページは、すぐさま “キャッシュされた内容” が画面に表示されます。 これにより、ローディングを意識させないUXを提供できます。

バックグラウンドでのフェッチ状態を画面に表示したい場合は、次のコードを追加します。

  function App() {
          /* 省略 */
    const { isLoading, isError, data: response, error, isFetching } = useQuery(
          /* 省略 */
    return (
      <div className="columns">
        {isFetching && (
          <div className="app-loading">
            <span>Fetching...</span>
          </div>
        )}
          {/* 省略 */}
      </div>
    );
  }

このコードを追加すると、バックグラウンドでフェッチ中であることを画面上部に表示できます。

React Queryはミューテーションと呼ばれる更新機能を、useMutationフックとして提供しています。 次のTypescriptコードは、useMutationフックを用いてアカウントの有効/無効を実現している個所の抜粋です。

  import { useQuery, useMutation } from "react-query";

  // アカウント一覧取得
  const { isLoading, isError, data: response, error, isFetching, refetch /* refetchを追加 */} = useQuery(/* 省略 */);
  // アカウントの有効/無効
  const [mutateAccount, { isLoading: isUpdating }] = useMutation(
    (account: Account) => {
      const accountCtl = new AccountController();
      return account.enabled
        ? accountCtl.disableAccount(account.id)
        : accountCtl.enableAccount(account.id);
    }, {
      onSuccess: async () => {
        /* 更新成功時に呼ばれる */
        await refetch();
      },
    }
  );

  /* 省略 */

  onClick={() => {
    mutateAccount(selectAccount!, {
      onSuccess: () => {
        /* refetch() 実行後に呼ばれる */
        setSelectAccount(undefined);
      },
    });
  }}

useMutationフックの戻り値として、次のような(これが全数ではありません)情報が返ります。

  • mutate: Function(variables, { onSuccess, onSettled, onError, throwOnError }) => Promise
    • 変数を使って呼び出すことができる更新トリガーとなる関数(以下、更新トリガー関数)
    • 更新トリガー関数呼び出し時に(任意で)定義したライフサイクルコールバック関数は、useMutationフックのオプションで定義された同じ型のものので実行されます
  • isLoading: Boolean
    • 更新中

また、このフックを呼び出すにあたり、次の2つの引数を渡します。

  • 更新する非同期関数
  • 更新実行時の設定やライフサイクルコールバック関数

ここでは、アカウントの有効/無効を(アカウントの状態に応じて)呼び出す非同期関数を渡しています。 useMutationフックを宣言しただけでは、更新は実行されません。 更新を実行するには、戻り値の更新トリガー関数を呼び出します。

更新実行時にはライフサイクルコールバックが呼ばれます。 ライフサイクルコールバック関数の指定は任意で、次のものが用意されています。

  • onMutate: Function(variables) => Promise | snapshotValue
    • 更新が実行される前に呼ばれる
  • onSuccess: Function(data, variables) => Promise | undefined
    • 更新が成功したときに呼ばれる
  • onError: Function(err, variables, onMutateValue) => Promise | undefined
    • 更新がエラーのときに呼ばれる
  • onSettled: Function(data, error, variables, onMutateValue) => Promise | undefined
    • 更新が成功、もしくはエラーのどちらの場合にも呼ばれる

上記コードにおいては、更新が成功した際にrefetch()関数を呼び出しています。refetch()関数はuseQueryフックの戻り値として新たに受け取った関数で、クエリーを手動でリフェッチできます。 これにより、更新後にクエリー結果のキャッシュを置き換えています。

更新トリガー関数の戻り値となるPromiseは、ハンドラー関数が呼ばれた後に解決されます。 そのため、上記コードは次の順に実行されます。

  1. accountCtl.disableAccount() or accountCtl.enableAccount()
  2. refetch()(ただし成功した場合)
  3. setSelectAccount()

上記内容を組み込んだ、アカウント一覧画面の全ソースコードがこちらです。

  import React, { useState } from "react";
  import { useQuery, useMutation } from "react-query";
  import { AccountController, Account } from "./api/api";
  import "./App.css";
  
  function App() {
    const [page, setPage] = useState(0);
    const [idSort, setIdSort] = useState<"ASC" | "DESC">("ASC");
    // アカウント一覧取得
    const { isLoading, isError, data: response, error, isFetching, refetch } = useQuery(
      ["accounts", { page, idSort }],
      (key, { page, idSort }) =>
        new AccountController().getAccounts(page, 10, `id,${idSort}`)
    );
    const [selectAccount, setSelectAccount] = useState<Account | undefined>(
      undefined
    );
    // アカウントの有効/無効
    const [mutateAccount, { isLoading: isUpdating }] = useMutation(
      (account: Account) => {
        const accountCtl = new AccountController();
        return account.enabled
          ? accountCtl.disableAccount(account.id)
          : accountCtl.enableAccount(account.id);
      }, {
        onSuccess: async () => {
          /* 更新成功時に呼ばれる */
          await refetch();
        },
      }
    );
  
  if (isLoading) {
      // ローディング中
      return (
        <div className="app-loading">
          <span>Loading...</span>
        </div>
      );
    }
  
    if (isError) {
      // エラー発生
      return <span>Error: {error?.message}</span>;
    }
  
    return (
      <div className="columns">
        {isFetching && (
          <div className="app-loading">
            <span>Fetching...</span>
          </div>
        )}
        <div className="column is-1"></div>
        <div className="column">
          <h1 className="title">Demo</h1>
          <table className="table is-fullwidth">
            <thead>
              <th>
                id
                <span
                  className="app-is-clickable"
                  onClick={() => {
                    setIdSort(idSort === "ASC" ? "DESC" : "ASC");
                  }}
                >
                  {idSort === "ASC" ? " ↓ " : " ↑ "}
                </span>
              </th>
              <th>name</th>
              <th>enabled</th>
            </thead>
            <tbody>
              {response!.data.content?.map((account) => (
                <tr key={account.id}>
                  <th>{account.id}</th>
                  <td>{account.name}</td>
                  <td>
                    <a href="/#" onClick={() => setSelectAccount(account)}>
                      {account.enabled ? "有効" : "停止"}
                    </a>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
  
          {/* navigation */}
          <nav className="pagination">
            {!response?.data.first && (
              <a
                href="/#"
                className="pagination-previous"
                onClick={() => setPage(page - 1)}
              >
                前へ
              </a>
            )}
            {!response?.data.last && (
              <a
                href="/#"
                className="pagination-next"
                onClick={() => setPage(page + 1)}
              >
                次へ
              </a>
            )}
            <ul className="pagination-list"></ul>
          </nav>
        </div>
        <div className="column is-1"></div>
 
        {/* modal */}
        <div className={"modal" + (selectAccount ? " is-active" : "")}>
          <div className="modal-background"></div>
          <div className="modal-card">
            <header className="modal-card-head">
              <p className="modal-card-title">{selectAccount?.name}</p>
              <button
                className="delete"
                onClick={() => setSelectAccount(undefined)}
                disabled={isUpdating || isFetching}
              ></button>
            </header>
            <section className="modal-card-body">
              このアカウントを{selectAccount?.enabled ? "停止" : "有効に"}します。
            </section>
            <footer className="modal-card-foot">
              <button
                className={
                  "card-footer-item button is-success" +
                  (isUpdating ? " is-loading" : "")
                }
                onClick={() => {
                  mutateAccount(selectAccount!, {
                    onSuccess: () => {
                      /* refetch() 実行後に呼ばれる */
                      setSelectAccount(undefined);
                    },
                  });
                }}
                disabled={isUpdating || isFetching}
              >
                OK
              </button>
            </footer>
          </div>
        </div>
      </div>
    );
  }
  
  export default App;

このコードを実行すると、次の画面が表示されます。

React Queryのグローバル設定を用いたトラフィック改善案

先に述べたとおり、React Queryは裏(デフォルトの設定)で様々なことをしています。 例えば、再レンダリングやブラウザウィンドウへの再フォーカス時のバックグラウンドでのリフェッチです。 画面上ではクエリー結果のキャッシュが表示されているため、システム利用者はローディングを意識しません。 しかしながら、データ変更の頻度が少ないデータを表示する場合、これらのデフォルト動作はシステムに余分な負荷を与える結果となっています。

React Queryでは、こういったデフォルト設定をクエリー毎、またはグローバルな方法で変更することが出来ます。 トラフィック改善を目的として、次の内容をグローバルな設定として定義します。

  • クエリー結果が”古くなるまで”の時間を5分とする
  • ブラウザウィンドウへの再フォーカス時に自動的なリフェッチをしない

これを実現したのが次のコードです。

  /* 省略 */
import { ReactQueryConfigProvider } from 'react-query'
  /* 省略 */

const queryConfig = {
  queries: {
    staleTime: 5 * 60 * 1000,
    refetchOnWindowFocus: false,
  },
}

ReactDOM.render(
  <React.StrictMode>
    <ReactQueryConfigProvider config={queryConfig}>
      <App />
    </ReactQueryConfigProvider>
  </React.StrictMode>,
  document.getElementById('root')
);
  /* 省略 */

ReactQueryConfigProviderを用いることで、React Queryのデフォルト設定をグローバルに変更できます。

まとめ

以上が我々のチームでReact Queryを使った事例の紹介でした。 実際にReact Queryを使用した感想は次の通りです。

  • 非同期処理を宣言的に記述できるようになり、状態に応じたレンダリング定義が分かりやすくなった
  • イレギュラーな状態(非同期処理の応答時にコンポーネントが破棄されているなど)をReact Queryが吸収してくれるので、開発負担が減った

この開発事例で紹介した内容は、React Queryの機能のほんの一部です。 今回紹介した内容以外でも、開発に役立つだろう機能が多く提供されています。

  • useInfiniteQueryを用いた無限スクロール
  • Dependent Queryを用いた、他のクエリー結果に依存したクエリーの実行
  • 初期データの事前準備
  • etc

これらの機能を使いこなすことで、より効率のいい開発が実現できると考えています。

今回紹介した開発技術は、他のReact案件でも有用な開発手法のひとつとして捉えています。 同様のシステム開発に関わるチームにおいて、本事例が参考になれば幸いです。


本コンテンツはクリエイティブコモンズ(Creative Commons) 4.0 の「表示—継承」に準拠しています。