TanStack Queryにて、useQueryのstaleTimeとcacheTimeの挙動を確認してみた

TanStack Queryの useQuery を使うとき

  • staleTime
  • cacheTime

という2つの設定値があります。

それらの違いについてよく分からなかったので調べてみたところ、Githubのdiscussionsに色々記載がありました。
staleTime vs cacheTime · TanStack/query · Discussion #1685

また、そこからリンクされていたBlogでは

  • StaleTime: The duration until a query transitions from fresh to stale. As long as the query is fresh, data will always be read from the cache only - no network request will happen! If the query is stale (which per default is: instantly), you will still get data from the cache, but a background refetch can happen under certain conditions.

  • CacheTime: The duration until inactive queries will be removed from the cache. This defaults to 5 minutes. Queries transition to the inactive state as soon as there are no observers registered, so when all components which use that query have unmounted.

https://tkdodo.eu/blog/practical-react-query#the-defaults-explained

とありました。

 
では、 staleTime を経過したときに何が起こるかというと、useQuery では以下のタイミングで自動的にデータを取得するとのことです。

Stale queries are refetched automatically in the background when:

  • New instances of the query mount
  • The window is refocused
  • The network is reconnected
  • The query is optionally configured with a refetch interval

https://tanstack.com/query/latest/docs/react/guides/important-defaults

 
Blogにも同様の記載がありました。

So React Query is being smart and chooses strategic points for triggering a refetch. Points that seem to be a good indicator for saying: "Yep, now would be a good time to go get some data". These are:

  • refetchOnMount Whenever a new component that calls useQuery mounts, React Query will do a revalidation.

  • refetchOnWindowFocus Whenever you focus the browser tab, there will be a refetch. This is my favourite point in time to do a revalidation, but it's often misunderstood. During development, we switch browser tabs very often, so we might perceive this as "too much". In production however, it most likely indicates that a user who left our app open in a tab now comes back from checking mails or reading twitter. Showing them the latest updates makes perfect sense in this situation.

  • refetchOnReconnect If you lose your network connection and regain it, it's also a good indicator to revalidate what you see on the screen.

Finally, if you, as the developer of your app, know a good point in time, you can invoke a manual invalidation via queryClient.invalidateQueries. This comes in very handy after you perform a mutation.

https://tkdodo.eu/blog/react-query-as-a-state-manager#smart-refetches

 
読むだけだと理解しきれなかったことから、実際に試してみたときのメモを残します。

 
目次

 

環境

  • React 18.2.0
  • TanStack Query 4.32.6

環境は以前の記事と同じため、詳しくはそちらを参照してください。
Reactにて、useStateやuseEffectを使っていたところをTanStack Queryに置き換えてみた - メモ的な思考的な

 
また、今回の動作確認ではTanStack QueryのDevtoosを使うため、以下で軽くふれておきます。

 

TanStack QueryのDevtoolsについて

今回の動作確認ではTanStack QueryのDevtoolsを使って、 useQuery が管理している状態の可視化が行えます。
Devtools | TanStack Query Docs

 
例えば、データが fresh な場合はこんな表示です。

 
その後時間が経過し、 stale になったときの表示です。

 
また、Devtoolsの Data ExplorerQuery Explorer では、 useQuery でキャッシュしているデータを確認できます。

 

staleTimeとcacheTimeについて

まずは

  • staleTime
  • cacheTime

の経過前後で何が起こるのか、動作確認を行います。

 

コンポーネントの準備

冒頭で見た通り、TanStack Queryでは自動的にデータの取得が行われます。

今回は「New instances of the query mount」の時だけ自動的なデータ取得を行うように設定することで、意図しない更新がなされないよう、 useQuery へ設定を行います。

また、React Routerでルーティングを行うことで、「New instances of the query mount」が発生するようにします。

そのため、ここでは3つのコンポーネントを用意します。

 

現在時刻を表示するコンポーネント

以下のようにして、現在時刻をブラウザに表示するコンポーネントを用意します。

import {useEffect, useState} from "react";

export const Clock = () => {
  const [current, setCurrent] = useState(new Date())

  useEffect(() => {
    setTimeout(() => {setCurrent(new Date())}, 1000)
  }, [current])

  const padZero = (value: number) => value.toString().padStart(2, "0")

  return (
    <h2>{[current.getHours(), current.getMinutes(), current.getSeconds()].map((v) => padZero(v)).join(':')}</h2>
  )
}

 

useQueryを使っているコンポーネント

このコンポーネントは、以下の設定を含む実装とします。

  • useQueryのオプション
    • キャッシュの制御に関する設定
      • staleTimeは、6秒
      • cacheTimeは、3秒
    • refetchOnMountでのみデータの自動取得を行うようにするため、他の自動取得系は無効化
    • queryFn の結果取得は、2秒以上かかるようにする
      • 「キャッシュがなくデータが取得中である場合は画面に何も表示されない」など、データの表示をわかりやすくするため
    • useQuery を使っていないコンポーネントへのリンクを、 Link コンポーネントを使って定義
      • 「New instances of the query mount」を発生させるため

 
実装は以下です。

import {Clock} from "../../../components/Clock";
import {useQuery} from "@tanstack/react-query";
import {DefaultApi} from "../../../../types";
import {Link} from "../../../router";
import {ReloadButton} from "../../../components/query_key/ReloadButton";

const queryFn = () => new DefaultApi().fetchFishes({headers: {Prefer: 'dynamic=true'}}).then(async (response) => {
  // 表示をわかりやすくするため、待ち時間を入れる
  await new Promise(resolve => setTimeout(resolve, 2000))

  return response.data.fishes
})

export const QUERY_KEY = ['StaleTime']

const Page = () => {
  const {data} = useQuery({
    queryKey: QUERY_KEY,
    queryFn: queryFn,
    staleTime: 6000,
    cacheTime: 3000,
    // refetchOnMountだけを動かしたいため、他のは無効化しておく
    refetchOnWindowFocus: false,
    refetchOnReconnect: false,
    refetchInterval: false,
  })

  return (
    <>
      <Clock />

      <p>
        <Link to={"/time/stale_time/clock_only"}>Next</Link>
      </p>

      {data && (
        <ul>
          {data.map(f => <li key={f.id}>ID: {f.id} / Name: {f.name}</li>)}
        </ul>
      )}
    </>
  )
}

export default Page

 

useQueryを使っていないコンポーネント

useQuery を使っているコンポーネントの遷移先です。

このコンポーネントでは

だけを用意しています。

import {Link} from "../../../../router";
import {Clock} from "../../../../components/Clock";
import {ReloadButton} from "../../../../components/query_key/ReloadButton";
import {QUERY_KEY} from "../index";

const Page = () => {
  return (
    <>
      <Clock />

      <p>
        <Link to={"/time/stale_time"}>Back</Link>
      </p>
    </>
  )
}

export default Page

 

動作確認

まずは、 staleTimecacheTime の経過・未経過による挙動の違いを確認します。

staleTimecacheTime の組み合わせは以下の4パターンあるため、それぞれ試してみます。

  • staleTimeは未経過、cacheTimeも未経過
  • staleTimeは未経過、cacheTimeは経過済
  • staleTimeは経過済、cacheTimeは未経過
  • staleTimeは経過済、cacheTimeは経過済

なお、再掲となりますが、今回は以下の設定値としています。

  • staleTimeは、6秒
  • cacheTimeは、3秒

 

staleTimeは未経過、cacheTimeも未経過

ここでは、cacheTimeの経過とならない3秒以内に「New instances of the query mount」を発生させてみます。

具体的には、3秒以内に、

  1. useQueryを使っているコンポーネントを表示し、リンクをクリック
  2. useQueryを使っていないコンポーネントを表示し、リンクをクリック
  3. useQueryを使っているコンポーネントを表示

のすべての操作を完了させます。

この場合、3の時点ではまだcacheTimeが経過していません。

そのため、APIを呼ばずにキャッシュのデータを表示します。

 

staleTimeは未経過、cacheTimeは経過済

ここでは、cacheTimeの経過となる3秒よりも後、かつ、staleTimeの経過とならない6秒よりも前に「New instances of the query mount」を発生させてみます。

具体的には、

  1. useQueryを使っているコンポーネントを表示し、リンクをクリック
  2. useQueryを使っていないコンポーネントを表示し、3秒待ち、リンクをクリック
  3. useQueryを使っているコンポーネントを表示

の順で操作します。

 
この場合、3の時点ではすでにcacheTimeが経過していてキャッシュが存在しないため、再度APIを呼んでデータを表示することになります。

そのため、3.でデータを取得するまでの間、データがない状態で表示されます。

 

staleTimeは経過済、cacheTimeは未経過

ここでは、staleTimeの経過となる6秒よりも後に「New instances of the query mount」を発生させてみます。

具体的には、

  1. useQueryを使っているコンポーネントを表示し、6秒待ち、リンクをクリック
  2. useQueryを使っていないコンポーネントを表示し、リンクをクリック
  3. useQueryを使っているコンポーネントを表示

の順で操作します。

 
この場合、3の時点ではstaleTimeが経過しているのでデータが古いもののcacheTimeが経過していないのでキャッシュにはデータが残っています。

そのため、3ではいったん古いデータを表示しつつ、バックグラウンドで再度APIを呼んでデータを取得し、取得し終わった時点でデータが差し替わります。

 

staleTimeは経過済、cacheTimeも経過済

ここでは、staleTimeの経過となる6秒よりも後、かつ、cacheTimeが経過した後に「New instances of the query mount」を発生させてみます。

具体的には、

  1. useQueryを使っているコンポーネントを表示し、6秒待ち、リンクをクリック
  2. useQueryを使っていないコンポーネントを表示し、3秒待ち、リンクをクリック
  3. useQueryを使っているコンポーネントを表示

の順で操作します。

この場合、3.の時点ではstaleTime・cacheTimeがともに経過しています。

そのため、データの再取得が完了するまでの間は、データが空で表示されます。

 

staleTime未経過時のinvalidateQueriesについて

queryClinet.invalidateQueries を使うと、staleTimeに関わらずデータの再取得が行われます。

ただ、 useQueryを使っていないコンポーネントinvalidateQueries を使った場合に、度のタイミングでデータの取得が行われるかが気になりました。

そこで今回、合わせて試してみることにしました。

 

コンポーネントの準備

先ほどまで使っていた各コンポーネントに、 invalidateQueries を実行する ReloadButton コンポーネントを追加します。

ReloadButton コンポーネントは以下のようなものです。

import {useQueryClient} from "@tanstack/react-query";

type Props = {
  queryKeyForReload: any[]
  exact?: boolean
}

export const ReloadButton = ({queryKeyForReload, exact = false}: Props) => {
  const queryClient = useQueryClient()

  const handleClick = () => {
    queryClient.invalidateQueries({queryKey: queryKeyForReload, exact: exact})
  }

  return (
    <button type={'button'} onClick={handleClick}>更新</button>
  )
}

 
コンポーネントへの組み込んだときはこんな感じになります。

useQueryを使っているコンポーネントです。

import {Clock} from "../../../components/Clock";
import {useQuery} from "@tanstack/react-query";
import {DefaultApi} from "../../../../types";
import {Link} from "../../../router";
import {ReloadButton} from "../../../components/query_key/ReloadButton";

const queryFn = () => new DefaultApi().fetchFishes({headers: {Prefer: 'dynamic=true'}}).then(async (response) => {
  // 表示をわかりやすくするため、待ち時間を入れる
  await new Promise(resolve => setTimeout(resolve, 2000))

  return response.data.fishes
})

export const QUERY_KEY = ['StaleTimeAndInvalidate']

const Page = () => {
  const {data} = useQuery({
    queryKey: QUERY_KEY,
    queryFn: queryFn,
    staleTime: 6000,
    cacheTime: 3000,
    // refetchOnMountだけを動かしたいため、他のは無効化しておく
    refetchOnWindowFocus: false,
    refetchOnReconnect: false,
    refetchInterval: false,
  })

  return (
    <>
      <Clock />

      <p>
        <Link to={"/time/stale_and_invalidate/clock_only"}>Next</Link>
      </p>
      <p>
        <ReloadButton queryKeyForReload={QUERY_KEY} />
      </p>

      <hr />

      <h3>Data</h3>
      {data && (
        <ul>
          {data.map(f => <li key={f.id}>ID: {f.id} / Name: {f.name}</li>)}
        </ul>
      )}
    </>
  )
}

export default Page

 
useQueryを使っていないコンポーネントです。

import {Link} from "../../../../router";
import {Clock} from "../../../../components/Clock";
import {ReloadButton} from "../../../../components/query_key/ReloadButton";
import {QUERY_KEY} from "../index";

const Page = () => {
  return (
    <>
      <Clock />

      <p>
        <Link to={"/time/stale_and_invalidate"}>Back</Link>
      </p>
      <p>
        <ReloadButton queryKeyForReload={QUERY_KEY} />
      </p>
    </>
  )
}

export default Page

 
準備ができたので、実際に試してみます。

 

useQueryを使っているコンポーネントで、staleTime未経過時にinvalidateQueries

ここでは、staleTimeが経過していないときに invalidateQueries を発生させてみます。

具体的には、

  1. useQueryを使っているコンポーネントを表示し、更新ボタンをクリック

と操作します。

この場合、1.の時点ではstaleTimeが未経過であっても、 invalidateQueries によりデータの再取得がなされます。

 

useQueryを使っていないコンポーネントで、staleTime未経過時にinvalidateQueries

ここでは、staleTimeが経過していないときに、 useQuery を使っていないコンポーネントinvalidateQueries を発生させてみます。

具体的には、

  1. useQueryを使っているコンポーネントを表示し、リンクをクリック
  2. useQueryを使っていないコンポーネントを表示し、更新ボタンをクリック
  3. useQueryを使っていないコンポーネントで、リンクをクリック
  4. useQueryを使っているコンポーネントを表示

の順で操作します。

この場合、

  • 2.の時点では、 invalidateQuery によるデータの取得が発生しない
  • 4.の時点で、staleTimeが未経過であっても、データの再取得が発生する

となります。

そのため、4ではいったん古いデータを表示しつつ、バックグラウンドで再度APIを呼んでデータを取得し、取得し終わった時点でデータが差し替わります。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/tanstack_prism_generouted-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/tanstack_prism_generouted-example/pull/4