microCMS

ReactQueryでキャッシュを最大限利用する

エンジニアリング
柴田 和祈

こんにちは、柴田(@shibe97)です。
久々に普通の技術記事です。

microCMSでは状態管理ライブラリとしてReact hooksベースのReactQueryを利用しています。
ReactQueryといえば、主にデータの取得時の状態管理の例が紹介されていることが多いです。

本家の例:

function Example() {
  const { isLoading, error, data } = useQuery('repoData', () =>
    fetch('https://api.github.com/repos/tannerlinsley/react-query').then(res =>
      res.json()
    )
  )

  if (isLoading) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
      <strong>👀 {data.subscribers_count}</strong>{' '}
      <strong>✨ {data.stargazers_count}</strong>{' '}
      <strong>🍴 {data.forks_count}</strong>
    </div>
  )
}


useQuery の第一引数はキャッシュの識別子のようなもので、全クエリにおいてユニークなキーを指定する必要があります。
useQuery のレスポンスにはdata /error /isLoading /isSuccess などたくさんのデータが返却されます。

参考:https://react-query.tanstack.com/reference/useQuery

これにより取得中のローディング処理や失敗時、成功時の処理を直感的に書くことができて便利です。

しかしこれはReactQueryの機能の一部に過ぎません。
個人的に、ReactQueryでもっとも活用すべきだと思う機能は「キャッシュ」です。

ReactQueryのキャッシュ機構

複雑なアプリケーションであればあるほど、様々なリクエストが投げられます。
表示パフォーマンスを高めるためにも無駄なリクエストはできる限り避けたいですよね。
そこで重要になってくるのが「キャッシュ」です。

ReactQueryでは取得したデータを自動的にキャッシュすることができ、その際の条件もかなり細かく設定することができます。
(ここでいうキャッシュとはJavaScriptのメモリによるキャッシュで、リロードするとリセットされます。)
また、複数箇所で同じリクエストが投げられてしまうようなケースでもReactQueryを使うことでひとつにまとめてリクエストを投げてくれたりします。

キャッシュの例:https://react-query.tanstack.com/guides/caching

cacheTime はデータをキャッシュする時間でstaleTime はキャッシュしたデータが古くなったとみなす時間を指します。
cacheTime のデフォルトは5分、staleTime のデフォルトは0です。

例えばAというデータを取得し、その1分後再度Aを取得し直す場合、すでにAはキャッシュされているのでリクエストなしで表示可能です。
一方でstaleTimeが0のため、キャッシュを返した後にバックグラウンドでフェッチが走ります。(Stale While Revalidate)
データが新しくなっている場合はそちらの値に書き換えられます。

microCMSの管理画面ではリクエストを最小限に抑えるために、staleTimeInfinity に設定しています。
どうなるかというと、キャッシュは常に新鮮なものとみなされるのでバックグラウンドでのフェッチは自動的には行われません。
microCMSはUGC(User Generated Content)のように頻繁にコンテンツが追加されるタイプのプロダクトではなく、管理者や編集者が画面からポチポチとデータを追加するケースがメインです。
そのため、ほとんどの場合に頻繁なデータ更新は必要無いのです。

データを再取得する必要があるとしたら、データを更新した時です。
ReactQueryでは次のようにキャッシュの無効化(Invalidate)を行い、データの再取得を促すことができます。

import { useMutation, useQueryClient } from 'react-query'

const queryClient = useQueryClient()

const mutation = useMutation(addTodo, {
  onSuccess: () => {
    queryClient.invalidateQueries('todos')
    queryClient.invalidateQueries('reminders')
  },
})


キャッシュの無効化が行われると、取得処理を行うタイミングではとりあえず古いキャッシュを表示しつつ、バックグラウンドで再取得が行われます。
再取得が完了されたタイミングで画面のデータは更新されます。

microCMSでは一度取得したデータは全てキャッシュし、明示的にInvalidateを行わない限り再取得を行わないという設計にしています。

柔軟なQuery Keysの指定

基本的にuseQueryの第一引数に指定した文字列単位でキャッシュを管理します。
ここは実は文字列だけでなく、配列形式での指定が可能です。これによりかなり細かい単位でのキャッシュが可能になります。

例えば、microCMSではコンテンツ一覧画面において、検索、フィルタリング、ソートといった単位でもキャッシュを行なっています。
(microCMSのアカウントをお持ちの方はぜひ、一覧画面で一度アクセスしたデータは全て即時表示できることを確認してみてください)

useQuery(['contentList', api.id, { q, size, offset, orders, filters }], ...)


また、このキャッシュを無効化する際には次のように無効化の範囲を指定することができます。

  • contentListにまつわる全てのキャッシュを無効化したい場合
queryClient.invalidateQueries(['contentList'])
  • contentListの中で一部のAPIに関するキャッシュを無効化したい場合
queryClient.invalidateQueries(['contentList', 'target-api-id'])


SPA更新系処理のツラミを解消する

今はSPA(Single Page Application)を開発している人もだいぶ増えてきたと思います。
SPAはサーバーサイドとクライアントサイドの両方で状態を管理する必要があります。
これがSPAの開発が複雑になるポイントです。

具体例を挙げると、下記のようなフローで処理を書いてあげる必要があります。

  1. アカウント情報を更新
  2. サーバサイドは更新されたが、画面表示は古いままなので、アカウント情報を再取得
  3. 最新のデータを表示


更新時のレスポンスとして更新後のデータが返却される場合は、クライアントサイドで保持しているデータの差し替えを行うこともできますが、処理が複雑になりがちなので、個人的には素直にGETし直すことの方が多いです。
updateAccount() の成功時にgetAccount() を呼ぶイメージです。
更新処理の影響範囲が広く、いろんなデータを再取得しなくてはならない場合などは処理がゴチャゴチャになりがちです。(遠くのファイルからGet関数をimportしてこなければならない等・・・)

これをReactQueryに置き換えると明示的にキャッシュの削除処理を行うだけで済むので、取得処理とは疎結合となり、コードの見通しが良くなったと感じています。

その他の有用なhooks

ReactQueryでは様々なhooksが用意されています。

useQueries

複数のクエリを同時に投げたい場合に利用できます。

const results = useQueries([
  { queryKey: ['post', 1], queryFn: fetchPost },
  { queryKey: ['post', 2], queryFn: fetchPost },
])

useInfiniteQuery

「もっと見る」形式で一覧にどんどんデータを追加していきたい場合に利用できます。
microCMSでも何箇所かで利用していますが、便利です。

const {
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  ...result
} = useInfiniteQuery(queryKey, ({ pageParam = 1 }) => fetchPage(pageParam), {
  ...options,
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})

useMutation

取得処理だけではなく書き込み処理を行うhooksも用意されています。
こちらはキャッシュ云々というよりは、書き込み処理時の状態管理がメインかなと思います。
ReactQueryを使っているのであれば、こちらも積極的に利用していくと良いでしょう。

function App () {
  const mutation = useMutation(newTodo => axios.post('/todos', newTodo))
  return (
    <div>
      {mutation.isLoading ? (
        'Adding todo...'
      ) : (
        <>
          {mutation.isError ? (
            <div>An error occurred: {mutation.error.message}</div>
          ) : null}
          {mutation.isSuccess ? <div>Todo added!</div> : null}
          <button onClick={() => mutation.mutate({ id: new Date(), title: 'Do Laundry' })}>
            Create Todo
          </button>
        </>
      )}
    </div>
  )
}


まとめ

自分が主にReactQueryが便利だと思う点は次の2点です。

  • 非同期通信の状態管理がしやすい
  • キャッシュ制御の最適化により、無駄な通信を最小限に抑えることができる


B向けの管理画面など、複雑なアプリケーションの場合は間違いなくオススメです。

また、ReactQueryとよく比較されるものとしてSWRがありますので、そちらを検討するのもありかと思います。
詳細な比較はこちらが参考になります。

ReactQuery、まだ触ったことない方はぜひ試してみてください。

-----

microCMSは日々改善を進めています。
ご意見・ご要望は管理画面右下のチャット、公式Twitterお問い合わせからお気軽にご連絡ください!
引き続きmicroCMSをよろしくお願いいたします!

ABOUT ME

柴田 和祈
microCMSのデザイン、フロントエンド担当 / ex Yahoo / 2児の父 / 著書「React入門 React・Reduxの導入からサーバサイドレンダリングによるUXの向上まで 」 / Jamstack

microCMSとは

  1. 開発者、編集者どちらも分かりやすい管理画面

  2. 細かな権限管理や豊富な外部サービス・データ連携

  3. 安心の日本製・日本語でのチャットサポート