React Queryを用いた開発方針
Status: Accepted
要約
React Queryを用いた開発方針について、検討の結果次の通りとします。
- React Queryのデフォルト設定について、「クエリ失敗時のリトライをしない」ように変更し、それ以外はデフォルトのままとする
- 古いデータが表示されることによるユーザの混乱を防ぐ為、データ更新後に古くなったキャッシュデータを破棄する
- 二重送信防止のため、ユーザ操作に応じて操作を制限する
- 共通のエラー処理は、React QueryのGlobal callbacksを利用して実現する
- HTTPステータスコード401応答時のセッション再接続は、axiosのInterceptorsなどの機能を利用して実現する
- ページネーションや無限スクロールに対応したバックエンドAPIの仕様をアプリ内で統一する
- クライアントコードの自動生成ツールにOrvalを採用する
- クエリキーの命名ルールはOrvalが自動生成したものに従う
コンテキスト
React Queryは非常に豊富な機能を用意しています。 これらの機能を活用することで、HTTP API通信を実現する上での様々な課題に対応できます。 しかしながら、全ての課題が解決するわけではありません。 例えば次にあげる課題については、アプリの特性に応じてそれぞれ方針を決める必要があります。
- React Queryのデフォルトオプションは何を設定すべきか
- データ更新などにより古くなったキャッシュデータをどう扱うか
- 二重送信をどのように防止すべきか
- 通信エラーをどのようにハンドリングすべきか
- ページネーションや無限スクロールをどのように実現するか
- クライアントコードの自動生成ツールは何を使うか
- クエリキーの命名ルールをどうするか
ここでは、上記課題解消を目的として、React Queryを用いた開発方針について検討します。
議論
React Queryのデフォルトオプション
React Queryには多数のオプションが用意されています。 これらのオプションは、全てのクエリやミューテーションに適用するデフォルト値を設定することが出来、かつクエリ・ミューテーション毎にその値を上書きできます。 ここでは、これらいくつかのオプションについて、このアプリの特性に応じた設定値を検討します。 特に、React Query公式ドキュメントのImportant Defaultsで示されるオプションについては、デフォルトのままで問題ないかを注意深く検討します。
クエリのデフォルトオプション
クエリのデフォルトオプションについて、検討結果は次の通りです。
| オプション | 検討結果 |
|---|---|
| queryFn | Default Query Functionで紹介されているとおり、デフォルトのクエリ関数を定義出来ます。これにより、SWRのようなシンプルな記述を実現することが出来ます。しかしながら、このアプリではOpenAPI仕様からソースコードを自動生成することにより、REST APIとの整合性や開発効率性の向上を図ります。その為、このオプションは利用しません。 |
| retry | クエリ失敗時のリトライ回数です。Important Defaultsで示されるとおり、デフォルト値は3です。このアプリでは、HTTP API 通信のリトライに従いリトライはユーザ自身の判断とします。falseをデフォルト値として設定します(リトライしません)。 |
| retryOnMount | マウント時にリトライするかどうかを示します。デフォルト値はtrueです。デフォルト値のままとします。 |
| retryDelay | リトライ時の遅延間隔を示します。デフォルトでは、リトライの度に指数関数的に待ち時間が増えていきます(Exponential Backoff)。デフォルト値のままとします。 |
| staleTime | クエリが「新しい」ものから「古くなる」までの期間です。クエリが「新しい」限り、クエリは取得済みのデータを返すため、ネットワークリクエストは発生しません。クエリが「古い」場合、クエリは取得済みのデータを返し、特定の条件下にてバックグラウンドで再フェッチします。Important Defaultsで示されるとおり、デフォルト値は0です(フェッチ後すぐ古くなる)。デフォルト値のままとし、必要に応じて各クエリ毎に個別で設定することとします。 |
| cacheTime | 未使用なクエリを削除するまでの期間です。クエリを使用するすべてのコンポーネントがアンマウントされると、そのクエリは未使用となります。Important Defaultsで示されるとおり、デフォルト値は5分です。デフォルト値のままとし、必要に応じて各クエリ毎に個別で設定することとします。 |
| refetchOnMount | マウント時に再フェッチするかどうかを示します。デフォルト(true)では、データが古くなっている場合に再フェッチします。デフォルト値のままとします。 |
| refetchOnWindowFocus | ウィンドウフォーカス時に再フェッチするかどうかを示します。デフォルト(true)では、データが古くなっている場合に再フェッチします。デフォルト値のままとします。 |
| refetchOnReconnect | 再接続時に再フェッチするかどうかを示します。デフォルト(true)では、データが古くなっている場合に再フェッチします。デフォルト値のままとします。 |
| structuralSharing | デフォルト(true)では、クエリ結果のデータの中身が変更されていない場合、データの参照が変更されません。これによりアプリのパフォーマンス向上が望めます。デフォルト値のままとしますが、クエリ結果の比較がパフォーマンス上の問題を引き起こす場合は、個別でfalseに設定することにします。 |
ミューテーションのデフォルトオプション
ミューテーションのデフォルトオプションについて、検討結果は次の通りです。
| オプション | 検討結果 |
|---|---|
| retry | ミューテーション失敗時のリトライ回数です。デフォルト値は0です。デフォルト値のままとします。 |
| retryDelay | リトライ時の遅延間隔を示します。デフォルトでは、リトライの度に指数関数的に待ち時間が増えていきます(Exponential Backoff)。そもそもリトライはしないので、デフォルト値のままとします。 |
ミューテーションにおいては、検討の結果全てデフォルトのままで問題ないと判断しました。
デフォルトオプションの設定例
検討結果を反映したデフォルトオプションの設定例は次の通りです。
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
データ更新時のキャッシュの扱いについて
useQueryを呼びだすと、取得済みのデータがあればそれを返し、必要に応じてクエリをバックグラウンドで再フェッチします。
そして再フェッチ完了後に新しいデータで画面を更新します。
再フェッチ中に取得済みデータを表示する動作は、頻繁なローディングインジケーターの表示を抑えUX向上に役立ちます。
一方で、更新後も一時的に古いデータが見えるため、ユーザの混乱を引き起こす可能性があります。
詳細はCaching Examplesを参照してください。
React Queryの公式ドキュメントでは、更新後にデータを最新化する2つの方法が提示されています。
1つめは、ミューテーション成功時にキャッシュしたクエリを無効にする方法です。 これにより、対象となるクエリはバックグラウンドで再フェッチを試みます。 この方法の場合、再フェッチが終了するまで一時的に古いデータが表示されるため、ユーザの混乱を引き起こす可能性があります。
2つめは、ミューテーション成功時にレスポンスの値でキャッシュデータを更新する方法です。 パフォーマンスに優れた方法ですが、レスポンスで更新後の値を取得できることが前提となります。 また、やや実装が複雑なものとなります。
このアプリにおいては、更新後に古いデータが見えることなく、かつできるだけシンプルな方法でデータを最新化したいです。 そこで、ミューテーション成功時に再フェッチが必要なクエリのキャッシュデータを破棄することで、古いデータを表示させない方針とします。
コード例は次の通りです。
const mutation = useMutation(addTodo, {
onSuccess: () => {
queryClient.resetQueries('todos');
},
});
このコードでは、新しいタスクを追加した後にToDoリストのキャッシュデータを破棄します。 これにより再フェッチ中は画面にローディングインジケーターが表示されることになりますが、古いデータが表示されることはありません。
二重送信防止について
Webアプリケーション同様に、モバイルアプリにおいても二重送信を防ぐ仕組みが必要です。 しかし、商品購入のような重要処理の二重実行を完全に防ぐには、バックエンド側での対策(トークンを用いたチェックなど)が必要です。 バックエンド側の対策はここでの検討範囲外とし、ユーザの操作ミスによる二重送信防止について検討します。
対策として、次の案があります。
- 通信中も一切の操作を制限しない
- 通信中は対象ボタンのみを押下不可
- 通信中は対象画面全体を操作不可(画面遷移は可能)
- 通信中はアプリ全体を操作不可(画面遷移は不可能)
下の案にいくほどユーザ操作が制限されるため、ユーザビリティが落ちます。 一方で、下の案ほどユーザ操作を制限できるため、予期せぬ操作への考慮が不要となります。
このアプリにおいては、更新時の操作制限はそれほどユーザビリティに影響を与えないと判断し、次の方針とします。
- 画面の初期ロードなど、ユーザ操作に直接起因しない通信については、一切の操作を制限しない
- 検索ボタン押下など、ユーザ操作に起因して発生した通信については、対象ボタンのみを押下不可とする
- ユーザ設定更新のような再設定可能な更新操作については、対象ボタンのみを押下不可とする
- 商品購入のような重要操作については、アプリ全体を操作不可とする
エラーハンドリング
エラーハンドリングについては、HTTP API通信で発生するエラーのハンドリングに従います。 共通的なエラーハンドリング処理は、個別で実装せず共通化を検討します。 また、HTTPステータスコード401が返却された場合の通信リトライについても処理の共通化を検討します。
エラーハンドリングの共通化
useQueryのクエリオプションであるonErrorにハンドラ関数を設定することで、クエリで発生したエラーをハンドリングできます。
また、QueryClientのデフォルトオプションに設定することで、各クエリ毎にハンドラ関数を設定する手間が省けます。
しかしならが、上記方法だと次の課題が発生します。
- 個別にエラー処理を追加したいなどの理由により
useQueryのonErrorに独自のハンドラ関数を設定すると、QueryClientのデフォルトオプションに設定したものが上書きされる - 同じクエリを使用した画面が複数存在すると、エラー処理も複数回実行される(例えば同じトーストが複数表示される)
そこで、共通のエラーハンドラ関数はQueryCacheのonErrorに設定します。
そうすることで、上記に挙げた課題が解決します。
ミューテーションについても同様の対策とします。
詳細は次のドキュメントを参照してください。
HTTPステータスコード401返却時の通信リトライ
HTTPステータスコード401が返却された場合、新しいセッションIDを再取得しリトライする機能が必要です。 React Queryにも通信リトライ機能は用意されておりますが、一時的なネットワークエラーに対応するものであり、セッションの再接続などを実現するものではありません。そこで、axiosのInterceptorsなどの機能を利用してセッションの再接続を実現します。
ページネーションや無限スクロールへの対応
リモートにある膨大なデータからアプリ内で必要なデータのみを取得し表示するには、ページネーションや無限スクロールへの対応が必要です。 React Queryにはページネーションや無限スクロールの仕組みが用意されているのでそれに従います。
ここでは、これらを実現するために必要なバックエンドAPIの仕様について検討し、アプリ内で統一することとします。
ページネーション
ページネーションを実現する為には、バックエンドAPIのリクエスト項目にページ番号が必要です。 さらにはページサイズやソート順を指定できるのが望ましいです。 また、総ページ数を表示するにはレスポンスにその値が必要です。
ページネーションを実現するにあたり、標準的なAPI仕様は見当たりませんでした。 そこで、Spring Data REST Reference Guide の 5. Paging and Sortingを参考に次の通りとします。
| URLクエリパラメータ | 説明 |
|---|---|
| page | 開始ページ番号 |
| size | ページサイズ |
| sort | ソート項目 |
総ページ数や全要素数は、HTTPボディの項目として返却します。
| HTTPボディの項目 | 説明 |
|---|---|
| content | レスポンスデータ |
| empty | ページが0件かどうか |
| first | 最初のページかどうか |
| last | 最後のページかどうか |
| number | 何ページ目か |
| size | ページサイズ |
| sort | ソート項目 |
| numberOfElements | ページに含まれる要素の件数 |
| pageable | リクエストで指定したpage、size、sortを保持するオブジェクト |
| totalElements | 全要素数 |
| totalPages | 総ページ数 |
それぞれの項目は、ページネーションを実現するすべてのAPIに用意する必要はなく、APIごとに必要な項目を取捨選択することとします。
無限スクロール
React QueryのuseInfiniteQueryフックは、読み込んだページのキャッシュデータを配列(data.pages)で保持します。
追加読込みをしたレスポンスデータは、その配列にページとして追加されます。
無限スクロールのよくある例として、アプリはそのキャッシュデータを1つの画面に全て表示します。
その為、ページネーションAPIの仕様を無限スクロールに利用した場合、次の恐れがあります。
- 読込み済みページの範囲でデータの追加が行われると、後続の追加読込みで重複されたデータが読み込まれる
- 読込み済みページの範囲でデータの削除が行われると、表示されないデータが出る
そうした事象を避けるため、無限スクロールのAPI仕様はページネーションのそれとは別で定義します。
無限スクロールを実現する為には、カーソル(ソート可能なID)が必要です。 さらには最大取得件数を指定できるのが望ましいです。 また、レスポンスには次のデータ位置を指し示すカーソルが必要です。
無限スクロールを実現するにあたり、標準的なAPI仕様は見当たりませんでした。 そこで、Infinite Queriesのサンプルコードを参考に次の通りとします。
| URLクエリパラメータ | 説明 |
|---|---|
| cursor | カーソル |
| limit | 最大取得件数 |
データ位置を指し示すカーソルは、HTTPボディの項目として返却します。
| HTTPボディの項目 | 説明 |
|---|---|
| content | レスポンスデータ |
| hasPrevious | 前のデータがあるかどうか |
| previousCursor | 前のカーソル |
| hasNext | 次のデータがあるかどうか |
| nextCursor | 次のカーソル |
それぞれの項目は、無限スクロールを実現するすべてのAPIに用意する必要はなく、APIごとに必要な項目を取捨選択することとします。
クライアントコードの自動生成について
OpenAPI仕様があると、そこからクライアントコードを自動生成できます。 これは生産性向上、および品質向上に役立ちます。 このアプリにおいてもバックエンドAPIのOpenAPI仕様が用意されています。 そこで、ここではクライアントコードの自動生成ツールについて検討します。
React Queryを使う場合の自動生成ツールとしては、次の候補があります。
それぞれにおいて比較検討しました。 比較結果は次のとおりです。
| OpenAPI Generator | Orval | |
|---|---|---|
| ライセンス | Apache License 2.0 | MIT License |
| 開発母体 | OpenAPI Tools | 個人 |
| 人気 | 11k star(GitHub) | 367 star(GitHub) |
| 機能 | △ | 〇 |
| 実績 | ◎ | △ |
| 生成コードの品質 | 〇 | ◎ |
ライセンス
OpenAPI GeneratorのライセンスはApache License 2.0です。 OrvalのライセンスはMIT Licenseです。 いずれも商用利用において問題ありません。
開発母体
開発母体はOpenAPI GeneratorがOpenAPI Toolsという組織での開発に対し、Orvalは個人が開発しています。 OpenAPI Generatorのほうが開発母体として安定性を感じます。
人気
GitHubのスターで確認する限り、OpenAPI Generatorのほうが圧倒的に知名度があります。
機能
Generators Listにあるとおり、OpenAPI GeneratorはFetch APIやaxiosを使用したクライアントコードを自動生成できます。
一方で、まだReact Queryは対応していないようです。
そのため、API毎にuseQueryを使用したカスタムフックは用意する場合、別途実装する必要があります。
OrvalはReact Queryにも対応しており、API毎にuseQueryを使用したカスタムフックが自動生成されます。
このアプリに限定するのであれば、Orvalのほうが必要な機能は揃っています。
実績
定量的な数値は抑えておりませんが、OpenAPI Generatorを用いた開発実績は多数あります。
生成コードの品質
単純なToDoアプリのOpenAPI仕様を用いてクライアントコードを生成し、その品質を比較検討しました。
| OpenAPI Generator | Orval | |
|---|---|---|
| コード(LOC) | 296 | 116 |
| コメント(LOC) | 378 | 59 |
| 保守性 | △ | ◎ |
Orvalと比較すると、OpenAPI Generatorで生成したコードのステップ数は倍以上あります。 定性的な比較となりますが、OpenAPI GeneratorよりOrvalの生成コードのほうが読みやすいと感じました。 自動生成ツールが使えなくなったなどの理由で、手作業で生成コードを保守する必要性が将来的に出た場合、Orvalのほうが保守しやすいです。
比較検討結果
開発母体および実績を考慮するとOpenAPI Generatorが無難に感じます。 しかしながら、自動生成ツールそのものよりも、プロダクトコードに入る生成されたコードの品質が大切です。 機能面および生成コードの品質でOrvalは魅力的に見えます。 チーム内で議論した結果、React Queryへの対応、および生成コードの品質を主な理由としてOrvalを採用します。
クエリキーの命名ルール
React Queryはクエリキーに基づいてクエリキャッシュを管理します。 別々のクエリに誤って同じクエリキーを使用すると、思わぬ不具合が生じます。 こういったトラブルを避ける為、クエリキーの命名ルールを定める必要があります。
クエリキーの命名ルールとして、当初は次の3案を検討する予定でした。
- React Queryの公式ドキュメントで紹介されているEffective React Query Keysに従いクエリキーを階層化(※1)して定義する
- REST APIのURL内のPathとQuery部をクエリキーとして用いる
- OpenAPIのoperationIdをクエリキーとして用いる
(※1)['todos', 'list']や['todos', 'detail', 1]のような階層構造。
しかしながら、クエリキーもOrvalの自動生成コードに含まれます。 Orvalは案2の命名ルールでクエリキーを生成します。 特にその案で懸念はないため、クエリキーはOrvalの命名ルールを採用します。
決定
React Queryを用いる上での開発方針について検討しました。 このアプリでは、以下それぞれの課題に対して次の方針をとります。
| 課題 | 結論 |
|---|---|
| React Queryのデフォルトオプションは何を設定すべきか | クエリオプションのretryをfalseに設定する以外はデフォルトのままとします。 |
| データ更新などにより古くなったキャッシュデータをどう扱うか | データ更新後に古くなったキャッシュデータを破棄することで古いデータを表示させません。 |
| 二重送信防止 | ユーザ操作に応じて操作を制限します。 |
| エラーハンドリングの実装方針 | React QueryのGlobal callbacks機能を利用して共通エラー処理を実現します。また、axiosのInterceptorsなどの機能を利用してセッションの再接続を実現します。 |
| ページネーションや無限スクロールへの対応 | バックエンドAPIの仕様をアプリ内で統一します。 |
| クライアントコードの自動生成について | 開発母体および実績を主な理由としてOpenAPI Generatorを採用します。 |
| クエリキーの命名ルールをどうするか | クエリキーの命名ルールはOrvalが自動生成したものに従います。 |