Zenn
Closed75

読む:TanStack Query 〜プロダクトで採用するための勘所〜

Yug (やぐ)Yug (やぐ)

ふむ

本書はハンズオン形式の構成ではありません。そのため興味のあるトピックだけ読んでいただいても理解が得られるように意識をしています。

Yug (やぐ)Yug (やぐ)

効率的な非同期状態の管理、については知らなかった

早速ですがTanStack Queryの主な役割は以下の3つです。

  • データフェッチ
  • 取得データのキャッシュ
  • 効率的な非同期状態の管理

ここで重要なのはTanStack Queryが単なるデータフェッチライブラリではないということです。むしろ主な目的は効率的な非同期状態の管理にあります。これはReact Summit2023でDominikさんが登壇した際にも強く強調されています。

Suspenseの類似概念だろうか。同期状態をちゃんと表現するためのクラスの独自実装みたいな話
https://zenn.dev/uhyo/books/react-concurrent-handson/viewer/render-as-you-fetch

Yug (やぐ)Yug (やぐ)

へーReduxってこんな感じなんだ

const slice = createSlice({
  name: "sample",
  initialState: {
    isLoading: false
  },

  extraReducers: (builder) => {
    builder.addCase(getIssue.pending, (state, action) => {
      state.isLoading = true;
    })
    builder.addCase(getIssue.rejected, (state, action) => {
      state.isLoading = false;
    })
    builder.addCase(getIssue.fulfilled, (state, action) => {
      state.isLoading = false;
    })
  }
});
Yug (やぐ)Yug (やぐ)

tanstack queryはすごくコード量少なく書けるのか

const { data, isPending } = useQuery({
  queryKey: ["issues"],
  queryFn: () => axios.get("/issues").then(res => res.data)
});

dataというグローバル変数として作れるのは良いな
んでif文書かずに勝手にisPendingが返されるのか、凄いな

でもisPendingが変更された時にその変更をReactは検知して再レンダリングちゃんとするのかな?
さすがにしてくれそう。であればめっちゃ助かる

Yug (やぐ)Yug (やぐ)

なるほど

TanStack Queryの強みの一つはこのように非同期の状態を自前で実装することなく管理できる点です。

Yug (やぐ)Yug (やぐ)

ほー、その記事も今度見る

さらにTanStack Queryが生まれた意義を説明するためには、状態管理の概念にClient StateとGlobal StateそしてServer Stateという3つの状態が存在することを理解する必要があります。"「3種類」で管理するReactのState戦略"という記事を参照すると、それぞれ以下の分類がされています。

グローバル状態がよくわかってない
useContext使ってもContext.Providerで囲んだ子コンポーネントにしかcontext渡せないのが面倒でよくわからなかった

完全にグローバルでポンポンどこからでもuseContext()使えば使用できるとかが理想だったけども
Providerの内部実装的にchildrenに値を渡すというかchildrenを使用するようになってるからなぁ、うーむ

Yug (やぐ)Yug (やぐ)

なるほど~

つまり、私たちがReduxなどのGlobal Stateによって管理していたサーバーの状態はあくまでもスナップショットにすぎず、常に最新の状態を保証できるものではありませんでした。その意味でも責務を分けて、Server Stateを適切に管理する必要性があります。

Server Stateに関係するものなのか、へぇ

TanStack Queryが登場する以前は責務が異なる二つの状態をまとめてGlobal Stateで管理していたことが問題だったということです。

つまりTanStack Queryは、Client State・Global StateとServer Stateの責務を正しく分割し、Server Stateの非同期で且つ状態が古くなり得る特徴を効率的に扱えるようにしたライブラリと言うことができます。この特徴をまず押さえることでTanStack QueryのAPIやその設計についての理解が深まると考えます。

Yug (やぐ)Yug (やぐ)

Chapter 03 useQueryの基本(データ取得)

そういやuseSuspenseQueryってやつも聞いたことあるな、違いが気になる

前章にてTanStack Queryの役割とその特徴を整理しました。本章ではTanStack Queryにおいて最も重要となるuseQuery APIについて解説していきます。

Yug (やぐ)Yug (やぐ)

ほーqueryKeyってやつが今回自分が求めてるキャッシュということか

const { data, isPending } = useQuery({
  queryKey: ["issues"],
  queryFn: () => axios.get("/issues").then(res => res.data)
})

useQueryの使い方は至ってシンプルで、queryKeyとqueryFnオプションにキャッシュ識別のためのkeyとデータフェッチのための関数を渡すだけです。

Yug (やぐ)Yug (やぐ)

うお~~すごいそんなことできるのか。助かるシーンがありそうだ

enabled

このオプションを活用することでクエリ同士に依存関係を持たせることができます。enabledオプションがfalseの場合はクエリが実行されません。以下の例ではユーザ情報の取得を待ってから、取得したユーザIDをもとにprojectのクエリを発火させています。

// ユーザ情報を取得
const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // ユーザIDの取得が完了されるまでクエリは発火されない
  enabled: !!userId,
})

であればやはりPromiseとかの非同期状態がfulfilled, rejected, pendingなのかを常に監視していることができていそう、かつ変更に伴い再レンダリングを走らせるみたいなこともやってくれてそう

すごい

Yug (やぐ)Yug (やぐ)

ほーこういうのをwaterfall問題というのか

Waterfall問題

enabledオプションはクエリ同士に依存関係を持たせることができるため非常に強力です。しかし、パフォーマンスに悪影響を与える可能性があります。それがWaterfall問題です。

Waterfall問題とは、リソースに対するリクエストが、別のリクエストが終了するまで開始されない場合に起こる現象です。まさにenabledオプションを用いたクエリの依存関係がそれに当たります。

上のコード例では、emailをもとにユーザ情報を取得し、取得したユーザ情報からプロジェクトの情報を取得しています。つまり、クエリの依存関係がなければ1回で済むサーバのやり取りが2回に増えてしまっています。

んーでもuserIdないとプロジェクト登録できないならしょうがないんじゃないのか?
非同期処理を2回走らせないといけないのは必至では?回避策なんてあるのか?よくわかっていない

Yug (やぐ)Yug (やぐ)

ほー、デフォルトでキャッシュは5分で捨てられるのか、なるほど

gcTimeも同様にnumber型もしくはInfinityを設定します。デフォルトは5 * 60 * 1000(5分)になっており、Infinityを設定した場合はGCを無効化することができます。

Yug (やぐ)Yug (やぐ)

そもそもstaleとfreshとは何だろう、何が古い/新しいんだろう

staleTimeにはnumber型もしくはInfinityを設定します。デフォルトは0になっており、データが取得された直後にキャッシュがstale状態になります。Infinityを設定した場合はキャッシュが自動的にstale状態になることはなく、freshな状態が維持されます。

mdnにあったので見る
https://developer.mozilla.org/ja/docs/Web/HTTP/Caching#age_に基づく新鮮さと古さ

Yug (やぐ)Yug (やぐ)

なるほどレスポンスが生成されてから何秒経ったかを表すもの、つまりageか
今回はレスポンス(=キャッシュ)のageをstale/freshで表しているということかな

んでfreshでいられる期間はmax-ageとして表される

ここが大事だ

格納されたレスポンスが新鮮である限り、クライアントのリクエストを履行するために使用されます。

つまり具体的には「freshである間はfetchを毎回発火せず、キャッシュをそのまま使う」ということになるのだと思う

Yug (やぐ)Yug (やぐ)

理解した上で再度説明を見る

staleTimeはキャッシュをstale状態にするまでの時間です。
staleTimeにはnumber型もしくはInfinityを設定します。デフォルトは0になっており、データが取得された直後にキャッシュがstale状態になります。Infinityを設定した場合はキャッシュが自動的にstale状態になることはなく、freshな状態が維持されます。

デフォルトが0になってる...?それってすぐstaleになってしまうということであって実質キャッシュとしての機能を果たしていないことにならないのか?

staleであると検知された結果毎回fetchが走ってしまうような気がするが

もしそうであれば、自分でここ設定しないとまずいかもなぁ

Yug (やぐ)Yug (やぐ)

claude 3.7に聞いたらほぉ~になった。なら大丈夫そうだ


おっしゃる疑問はとても鋭いですね。TanStack Query(旧React Query)のstaleTimeのデフォルト値が0であることについて説明します。

TanStack Queryでは、HTTPキャッシュとは少し異なる独自のキャッシュ戦略を採用しています。確かにstaleTimeのデフォルト値は0ですが、これは「すぐにデータがstaleになる」という意味であり、必ずしも「毎回fetchが走る」ということではありません。

TanStack Queryのキャッシュ動作の仕組みは以下のようになっています:

  1. staleTime: データが「新鮮(fresh)」と見なされる期間。デフォルトは0ms(すぐにstaleになる)

  2. cacheTime: データがキャッシュに残る期間。デフォルトは5分(300000ms)

重要なポイントは、データがstaleになってもすぐにfetchが実行されるわけではないということです。以下の場合にのみfetchが実行されます:

  • コンポーネントが初めてマウントされたとき
  • ウィンドウが再フォーカスされたとき(refetchOnWindowFocus: trueの場合)
  • ネットワーク接続が回復したとき(refetchOnReconnect: trueの場合)
  • 明示的にrefetchが呼び出されたとき
  • 設定されたrefetchIntervalの間隔が経過したとき

つまり、staleTimeが0でもcacheTimeの間はキャッシュされたデータが保持され、上記のイベントが発生したときのみ再取得が行われます。これは「キャッシュはあるけれど、できるだけ最新のデータを取得したい」という一般的なユースケースに対応しています。

より具体的に言うと:

  • staleTime=0は「キャッシュは常に古いと見なす」という設定
  • しかし実際に再取得するのは特定のイベント発生時のみ
  • その間はキャッシュされたデータを表示し続ける

このアプローチにより、UXとデータの鮮度のバランスを取っています。もし再取得の頻度を減らしたい場合は、staleTimeを長く設定することで対応できます。

Yug (やぐ)Yug (やぐ)

じゃあstaleTimeって何のために存在するんだって感じではあるが・・

あとcacheTimeというかgcTimeかも

Yug (やぐ)Yug (やぐ)

ほーおもしろ、非同期はだめっぽいから同期処理のみだな

select

このオプションはqueryFnで取得したデータを加工する際に便利です。

select: (data: TData) => unknown

selectオプションにはqueryFnで取得したデータを引数に受け取る関数を渡します。そして変換された値が最終的なdataとして返却されます。

Yug (やぐ)Yug (やぐ)

へーそんなのもあるのか

statusに加えてfetchStatusという値を扱うことができます。statusがデータに関する状態を定義した値だったのに対してfetchStatusはqueryFnの実行に関する状態を定義した値です。つまり、フェッチの実行に関する状態を扱うことができます。fetchStatusもstatusと同様に全部で3種類存在します。

Yug (やぐ)Yug (やぐ)

status要らないのでは?という気もするが

function Todos() {
  const { status, data, error } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodoList,
  })

  // if (isPending) {
  if (status === 'pending') {
    return <span>Loading...</span>
  }

  // if (isError) {
  if (status === 'error') {
    return <span>Error: {error.message}</span>
  }

  // also status === 'success', but "else" logic works, too
  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}
Yug (やぐ)Yug (やぐ)

なるほど、同意だ。isPendingを使おう

これは筆者の意見ですが、Loadingの表示にはisPendingを使うのが良いと考えます。

enabledオプションを使用してクエリの依存関係を構築したケースを想定します。クエリが無効化されている間フェッチは走らずisFetchingはfalseになります。つまり、isLoadingはtrueになりません。しかし、クエリの実行は完了しておらず表示するデータは存在しないため本来はLoadingの表示をしたいはずです。

isLoadingという一見そのままLoading表示のためのフラグとして使えそうな値ですが定義を理解せずに使うと意図しない挙動になってしまいます。isPendingとの違いを理解しながら使い分けていきましょう。

必要なのはfetch関数の実行状態ではなくデータの状態だしなぁ
でもそれでいうとfetch関数の実行状態なんて必要なケースあるのか?気になる

Yug (やぐ)Yug (やぐ)

Chapter 04 useMutationの基本(データ更新)

データ更新?fetchの結果を変更するということ?どういうことだろう

前章にてuseQuery APIのオプションと返り値について整理しました。useQueryがデータ取得のためのAPIだったのに対して、データの更新をするためにはuseMutationを使用します。本章ではuseQuery同様にuseMutationのオプションと返り値について解説をします。

Yug (やぐ)Yug (やぐ)

あー単純にPOSTとかのことをデータの更新と言っているのか、なるほど

function App() {
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return axios.post('/todos', newTodo)
    },
  })

  return (
    <button
      onClick={() => {
        mutation.mutate({ id: new Date(), title: 'Do Laundry' })
      }}
    >
      Create Todo
    </button>
  )
Yug (やぐ)Yug (やぐ)

んー?真逆の印象があるなぁ

useMutationはCRUD操作のうちCreate/Update/Deleteの操作を扱います。また、useQueryとuseMutationの大きな違いとして以下の点が挙げられます。

上のコード例ではデータ更新処理が実行されるのはuseMutationが呼ばれた時ではなく、<button>のonClick時です。データの更新は命令的に処理します。そのため、useMutationの返り値であるmutate関数を使用してデータ更新を任意のタイミングで実行する必要があります。

useQueryはすぐ実行されるので命令的であって宣言的って感じはしない
useMutationは宣言してるだけですぐ発火する訳ではなく、onClick時にならないと実行されないというところから命令的ではなく宣言的な感じがする

というかまぁ以下記事を見るに、「宣言的=目的の宣言」で「命令的=手段の命令」なので今回はどっちなのかというと、...よくわからん。あんま違いが見いだせない
https://zenn.dev/miyamonz/articles/3318bc87cf14cb

発火タイミングが違うだけ、て感じがする

あーでも返り値のmutateメソッドの引数はオブジェクトを直接書いてそれでミュータブルに変更するのか。これは確かに「手段の命令」かもしれない

useQueryは具体的な手段の命令は無いので、そういう意味では命令的ではないので宣言的か、ふむ

Yug (やぐ)Yug (やぐ)

このuseMutationを使うシーンはどういうときだろう

POSTの返り値を非同期で待っておきたい、かつそれをキャッシュしたい時?

あ、でも普通に使うか・・そもそもプロミスがキャッシュされていないからレンダリングごとに毎回fetchが走ってしまうというのが解決したい問題で、それを解決するためにtanstack queryを使うので、GETだけでなくPOSTもfetchである点は同じなのだからキャッシュはしないとだよね、て話か

そうか、GETしか頭になかったがPOSTも普通にするのでこれもガンガン使わせてもらうことになりそうだ

Yug (やぐ)Yug (やぐ)

またデフォルト関数というものが出てきたが、デフォルト関数というのはどうやって設定するのか気になる

mutationFnは必須で設定する必要がありますが、デフォルトでmutation関数を定義している場合はオプショナルになります。

Yug (やぐ)Yug (やぐ)

ReactでもuseOptimisticって面白そうなやつあったな

onMutate

このCallback関数ではmutationFnが実行される前に発火され、mutationFnが受け取る同じ変数が渡されます。
onMutateの使い道としては、データの更新が成功することを期待してあらかじめ描画を更新するOptimistic Updateを行う際などに有効です。

https://zenn.dev/uhyo/books/react-19-new/viewer/use-optimistic

Yug (やぐ)Yug (やぐ)
const {
  mutate,
  mutateAsync,
  status,
  isIdle,
  isPending,
  isError,
  isSuccess
} = useMutation()

あーじゃあ普通にuseQueryみたいに分割代入でも良さげか

const { mutate, isPending } = useMutation()
Yug (やぐ)Yug (やぐ)

「mutationFnで宣言しとく → その宣言を実際にmutateで実行(命令)」という流れで対応してそうだ
(宣言と命令が混ざってるから宣言的とも命令的ともやはり言いづらいな)

mutationFnの引数はmutateの引数と対応してる、まったく一緒

Yug (やぐ)Yug (やぐ)

なるほど~~~

それぞれの責務分け

実行順序はuseMutationのCallback関数が先であると説明しましたが、これだけではどちらに処理を書くべきか判断ができません。

双方の違いとしてもう一つ重要な点があります。

つまり、絶対に実行されるべきロジックはuseMutation側のCallback関数で行う必要があります。逆に、ユーザへのトースト通知などUIに関係するような処理はmutate関数側で行えば良いです。まとめると以下の様になります。

  • useMutation

    • 先に実行される。
    • unmountされても実行される。
    • クエリの無効化などの確実に実行されるべき処理に適している。
  • mutate

    • useMutationの後に実行される。
    • unmountされると実行されない。
    • トースト通知などUIに関係するような処理に適している。
Yug (やぐ)Yug (やぐ)

mutateと違ってmutateAsyncでは非同期を扱える、というのがよくわからない

mutateでも結局mutationFnで定義した非同期処理を実行してるからなぁ

function App() {
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return axios.post('/todos', newTodo)
    },
  })

  return (
    <button
      onClick={() => {
        mutation.mutate({ id: new Date(), title: 'Do Laundry' })
      }}
    >
      Create Todo
    </button>
  )

Suspenseせずにawait使っちゃうみたいなことができる、ということ?
どういう時にそんなことをするのかはわからない

まぁ、だからこそ基本的にはmutateを使ってokという主張なのだろう

基本的にはmutate関数を使えば良い

mutate doesn't return anything, while mutateAsync returns a Promise containing the result of the mutation. So you might be tempted to use mutateAsync when you need access to the mutation response, but I would still argue that you should almost always use mutate.

Yug (やぐ)Yug (やぐ)

あーでもmutateだと返り値が得られないけどmutateAsyncだと返り値をプロミスとして得られるから、結果をいろいろいじりたいみたいなニーズがあるのであれば、確かに存在意義はあるのかも

Yug (やぐ)Yug (やぐ)

うーむ

status

useMutationのstatusはuseQueryに比べてシンプルです。以下の4種類が存在します。

  • idle
    • mutationが実行される前の状態。
  • pending
    • mutationが実行されている状態。
  • error
    • mutationが実行され、エラーが起きた状態。
  • success
    • mutationが実行され、成功した状態。

また、useQuery同様にisIdle,isPending,isSuccess,isErrorといったstatusの状態を扱えるフラグも提供されています。

idleという「実行される前の状態」はuseQueryのstatusにはなかったけれどもそれはなぜだろう
https://zenn.dev/taisei_13046/books/133e9995b6aadf/viewer/c22ed5#status

あとonMutateみたいな「mutationFnが実行される前」に発火するみたいな立ち位置のやつもなかった
https://zenn.dev/taisei_13046/books/133e9995b6aadf/viewer/257b1a#onmutate

Yug (やぐ)Yug (やぐ)

ほー、具体例が気になる

これではどこでkeyの重複が起きてもおかしくありません。そのため、keyの管理はコンポーネントの中で行うのではなく一元的に管理することが重要です。

Yug (やぐ)Yug (やぐ)

なるほどな~

例)queryKeyのルールをREST API設計に依存させる

/todos → ['todos']
/todos?filter=['todos', 'list', { filters }]
/todos/${id}['todos', 'detail', id]

でもこれだと各エンドポイントでfetchを1個までしか使えないことになるからそこは注意か

Yug (やぐ)Yug (やぐ)

なるほど~

queryKeyのルールが決まったところで、どこで一元管理するべきかを考えます。

useQueryはqueryKeyとqueryFnを必須に受け取ることを説明しました。また、queryFnはcontextを通じてqueryKeyを参照することもできます。この様な関係性からもqueryFnとqueryKeyはColocateしている方が都合がいいです。

- src
  - service
    - todo
      - index.ts
      - key.ts 
      - function.ts
    - post
      - index.ts
      - key.ts 
      - function.ts

tanstack router記事のディレクトリ設計紹介に似てる
https://zenn.dev/aishift/articles/ad1744836509dd#tanstack-routerのディレクトリ設計

Yug (やぐ)Yug (やぐ)

ほ~

最後にkeyの利便性を高めることを考えます。queryKeyはuseQueryに渡す用途だけではありません。特定のキャッシュを直接指定する際もqueryKeyを使用します。

8章にて詳しく説明をしますが、キャッシュをinvalidateしてデータを再取得する際にもqueryKeyは必要になります。

Yug (やぐ)Yug (やぐ)

なるほど~~考えないといけないことが増えたな..

function useUpdateTodo() {
  return useMutation({
    mutationFn: updateTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ['todos', 'list']
      })
    },
  })
}

上のコード例では新規のTODOを追加した際にTODO一覧を再取得しています。TODOを追加したことからキャッシュに存在するTODO一覧のデータが古くなっていることが明らかなためです。

この様にqueryKeyはキャッシュを直接操作する際に使用されるケースがあります。

Yug (やぐ)Yug (やぐ)

確かにだ、勉強になる

ところで上のコード例は懸念点があります。それはキャッシュをinvalidateする際に文字列でqueryKeyを指定している点です。仮にqueryKeyの管理に変更があった場合、そのqueryKeyを使っている箇所全てが影響範囲になりますし、修正漏れがあればデグレの原因になります。

ぐお~難しい、なるほど

const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: number) => [...todoKeys.details(), id] as const,
}

queryKeyはuseQuery以外の箇所でも使用されることを念頭に入れファクトリー関数を作成しましょう。これにより使用する側では文字列で指定する必要がなくなり、保守性が向上します。

listが/todos/list/{filters}ってなるっぽいけど何を表してるのかよくわからない

けどとりあえず書き方のイメージはできた

Yug (やぐ)Yug (やぐ)

ていうかカスタムフック作ってみたいな、こういう感じで

const transformTodoNames = (data: Todos) =>
  data.map((todo) => todo.name.toUpperCase())

export const useTodosQuery = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    select: transformTodoNames,
  })
Yug (やぐ)Yug (やぐ)

お~、面倒だと思ってたので嬉しい

Genericsは避け、推論させる

ここまでuseQueryのGenericsについて解説をしましたが、一転してこれらGenericsの使用はなるべく避けるべきだとDominikさんは主張しています。とはいえ、useQueryのGenericsがどの様な役割を持つのかを知ることは重要であるため解説させていただきました。

Yug (やぐ)Yug (やぐ)

うおー、不便だなぁ

なぜGenericsを避けるべきか

useQueryには4つのGenericsがあり、そのうち1つのみを指定した場合残り3つがデフォルト値に戻ってしまいます。

下のコード例ではTQueryFnDataとTErrorのみをGenericsで指定してTDataの型を指定していません。そのため、TDataの型がデフォルトのTQueryFnDataと同じ型になってしまい、型エラーが発生しています。

function useGroupCount() {
  return useQuery<Group[], Error>({
    queryKey: ['groups'],
    queryFn: fetchGroups,
    select: (groups) => groups.length,
    // 🚨 Type '(groups: Group[]) => number' is not assignable to type '(data: Group[]) => Group[]'.
    // Type 'number' is not assignable to type 'Group[]'.ts(2322)
  })
}

HM型推論みたいな型推論アルゴリズムの欠陥ってことかな?
いやそもそもデフォルト値を指定してる時点でそれがデフォルトになるのは必然と言うだけの話か

じゃあデフォルト値を設定しなければ良いのでは?デフォルト値の存在意義って何だっけ?
まぁ今回は飛ばす

Yug (やぐ)Yug (やぐ)

あーじゃあfetcher関数ではちゃんと返り値の型定義しないといけないのか、注意だな

function fetchGroups(): Promise<Group[]> {
  return axios.get('groups').then((response) => response.data)
}

// ✅ data will be `Group[] | undefined` here
function useGroups() {
  return useQuery({ queryKey: ['groups'], queryFn: fetchGroups })
}

// ✅ data will be `number | undefined` here
function useGroupCount() {
  return useQuery({
    queryKey: ['groups'],
    queryFn: fetchGroups,
    select: (groups) => groups.length,
  })
}

ん、selectに渡す関数でも同じように定義しないとだ、そうか確かに
(この例だとしてなくても機能してるっぽいけど)

この例ではfetchGroups関数のReturn型をPromise<Group[]>と指定しています。するとuseQueryはTQueryFnDataがGroup[]型であると推論できます。

selectオプションについても同様です。Genericsを使用していない場合、TypeScriptの推論が効きnumber型であると分かります。

もちろんReturn型を指定せずとも推論できるケースは必要ありませんが、型指定をしないとanyになってしまう場合は必ず指定するようにしましょう。

Yug (やぐ)Yug (やぐ)

staleTimeはあんま関係なくてgcTimeがキャッシュを有効利用できる期間として重要なものって感じなのかな?やっぱり

Yug (やぐ)Yug (やぐ)

おーtanstack routerにもあったな、tanstack凄い

TanStack QueryにはDevToolsがありキャッシュの状態を確認することができ非常に便利です。ここではDevToolsの確認するべきポイントについて紹介します。

Yug (やぐ)Yug (やぐ)

エラーになっててよくわからないな

さっきも同じエラーになってたが、chakra uiが使えていない?

Yug (やぐ)Yug (やぐ)

unmount -> inactiveか

また、データ取得後unmountするとキャッシュはinactive状態になり、再度コンポーネントを描画するとisFetchingがtrueになります。

Yug (やぐ)Yug (やぐ)

あーやっぱstaleになってもキャッシュはちゃんと保持されてるっぽいな
んでバックグラウンドで最新取得 & 差分更新なんてしてるのか...!?賢すぎる

staleTimeが0の挙動として重要なのは以下の点です。

  • データが取得された直後にキャッシュがstale状態になる
  • キャッシュのデータが存在する場合、そのキャッシュを即座に返却し描画する。
  • バックグラウンドで最新のデータを取得し、差分があれば更新する。

なるほど

そのため、キャッシュが存在する状態ではisPendingが常にfalseですが、バックグラウンドでデータ取得しているのでisFetchingがtrueになります。

Yug (やぐ)Yug (やぐ)

なるほど~~~

staleTimeが0の挙動ではデータ取得直後にキャッシュがstale状態になりました。比較すると分かりますが、staleTimeがInfinityの場合はデータ取得後もキャッシュがfreshな状態で維持されます。

このメリットはコンポーネントをunmountし再度描画した場合に確認できます。

いかがでしょうか。staleTimeがInfinityの場合はisFetchingがfalseのままで、データ取得がされていないのがわかると思います。

refetchOnMount・refetchOnWindowFocus・refetchOnReconnectのタイミングでTanStack Queryは基本的にrefetchをします。そして、これらオプションがtrueの場合はキャッシュがstale状態の時にのみrefetchすることを説明しました。

つまり、staleTimeがInfinityの場合はデータ取得後もキャッシュがstale状態にならずfreshな状態が維持されることから、上記3つのタイミングでrefetchが実行されないため、データ取得が回避されるという流れになります。

補足としてrefetchOnMount・refetchOnWindowFocus・refetchOnReconnectを"always"に設定するとキャッシュがfreshな状態でもデータ取得がされます。このように各オプション設定の組み合わせによって挙動が変わってしまう点は非常に複雑ですね。

refetch〇〇をtrueにしてもstaleの時にしか発火しないんか、留意だな
(しかしalwaysにすれば問答無用で発火する、なるほど)

とりあえず自分的にもそんなrefetch走らせたいとかは無いかな
だがさっき見た通り、postした後に更新かけるってやつは絶対やらないとだろうな(invalidateだっけ?)

Yug (やぐ)Yug (やぐ)

あー「gcTimeは使われなくなったキャッシュのデータをGC(ガベージコレクション)するまでの時間」ていうのは具体的にそういう意味か

gcTimeとはこの様にキャッシュがinactiveになってからそのキャッシュを捨てるまでの時間を管理しています。

この検証環境ではgcTimeを0に設定しています。つまり、キャッシュが使われなくなったらすぐにそのキャッシュを破棄するという挙動です。

使われなくなった -> inactive
ガベージコレクションする -> 捨てる

Yug (やぐ)Yug (やぐ)

なるほど

gcTimeが0ではTanStack Queryのキャッシュにおける強みは生かされません。キャッシュをしたとしても毎回破棄されデータが再取得されるからです。

だからこそデフォルトのgcTimeは5minに設定されています。

Yug (やぐ)Yug (やぐ)

ふむ、自分はどうしようか

本章では文字では理解し難いstaleTimeとgcTimeの挙動を検証環境をもとに解説しました。特にstaleTimeの設定値はアプリケーションのビジネス要件に依存してきます。まずはデフォルトの挙動を理解しつつ、必要に応じてキャッシュの管理を最適化していきましょう。

超小規模な個人開発todoアプリとした場合、refetchはtrueにしておき、staleの場合のみrefetchを走らせるっていう普通っぽいやつで良いかな

だがCREATE/UPDATE/DELETEした後にrefetch走らせることができるとかさっき見たような(?)気がするのでそれさえやっちゃえばそれ以外のrefetchはもはや不要かもなぁ

なのでstaleはデフォルト通り0で良い気がする

Yug (やぐ)Yug (やぐ)

あーそっかそういうことか

TODOアプリケーションでは基本的に入力フォームとTODO一覧が同一の画面にあります。その場合、新規TODOを追加して直ぐにTODO一覧の表示を更新したいです。しかし、デフォルトの挙動ではTODO追加後にデータを更新してくれません。上の検証環境ではデータ追加後、リロードをすると正しくデータが更新される挙動が確認できます。

そこで特定のクエリを指定して再取得しましょう。TanStack Queryにおけるキャッシュのinvalidate処理とはデータが古くなったことが明らかな場合、直接queryKeyを指定してデータを更新することを意味します。

当然todo一覧のデータはstateで持っておくということはせずfetchの結果として常に反映させておくものなので、setTodos()で再レンダリングを起こすという単調な方法は使えないのか

よく考えたら当然か

だからmutate()の後に更新する方法が必要で、それこそinvalidateということか。理解

じゃあこれは自分も必要なやつだ

Yug (やぐ)Yug (やぐ)

すばらし

キャッシュのinvalidate方法はシンプルです。

function useUpdateTodo() {
  return useMutation({
    mutationFn: updateTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ['todos', 'list']
      })
    },
  })
}

やっぱonSuccessか

Yug (やぐ)Yug (やぐ)

なるほどーすごく書き方勉強になる

pages/index.tsx
const IndexPage: React.FC = () => {
  const [value, setValue] = useState('');

  const { data: todos, isPending, isError } = useGetTodos();
  const { mutate } = useUpdateTodos();

  const handleSubmit = useCallback(() => {
    mutate(value, {
      onSuccess: () => {
        setValue('');
      },
    });
  }, [value]);

  // loading UI
  if (isPending) <div></div>;

  // error UI
  if (isError) <div></div>;

  return (
    <Box p={4}>
      <Text fontSize="xl" fontWeight="bold" pt={1} pb={4}>
        TODO LIST
      </Text>
      <Box display="flex" gap={4}>
        <Input
          value={value}
          onChange={(e) => setValue(e.target.value)}
          width={200}
        />
        <Button colorScheme="teal" onClick={handleSubmit}>
          Submit
        </Button>
      </Box>
      <UnorderedList p={2}>
        {todos?.map((todo, i) => (
          <ListItem key={i}>{todo}</ListItem>
        ))}
      </UnorderedList>
    </Box>
  );
};

export default IndexPage;
service/todo/index.ts
export const useGetTodos = () => {
  const { data, isPending, isError } = useQuery({
    queryKey: todoKeys.all,
    queryFn: getTodos,
    select: getTodosSelector,
  });

  return {
    data,
    isPending,
    isError,
  };
};

export const useUpdateTodos = () => {
  const queryClient = useQueryClient();

  const { mutate } = useMutation({
    mutationFn: updateTodos,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: todoKeys.all }),
  });

  return { mutate };
};

onSuccessがuseMutationにもmutateにもある件に関しては前述の通りの使い方でok
https://zenn.dev/taisei_13046/books/133e9995b6aadf/viewer/257b1a#それぞれの責務分け

Yug (やぐ)Yug (やぐ)

すごい、バックグラウンドとはいえ画面にちゃんと更新反映される

invalidateQueriesを実行すると以下の挙動になります。

  • 指定したキャッシュがstale状態になる。
  • バックグラウンドでデータの再取得がされる。

これによって特定のクエリの状態を最新にすることができます。

Yug (やぐ)Yug (やぐ)

スッキリした、そういう感じなのか

refetch関数はどんな状態のクエリも実行してしまう

refetch関数でデータを再取得する挙動とキャッシュをinvalidateしてデータを再取得する挙動は完全に一致していません。その内容が下記のDiscussionsで議論されています。

refetch関数ではいかなる時もクエリを実行します。それがinactiveなクエリでもenabledオプションがfalseなクエリでも関係なく実行されます。

またDominikさんもinvalidationの方がより賢いデータの更新方法であると言っていることから、refetch関数によるデータ更新よりinvalidationによるデータ更新を積極的に採用していくことが良いと考えます。

やっぱrefetchは要らなそうだな

invalidateにrefetchも含まれているのでそれ使えばokだし、より賢いのでgood

invalidateQueriesという名前に、キャッシュをinvalidateするという意味だけに収まらずrefetchという意味も含まれてるので少々わかり辛い感もあるが

Yug (やぐ)Yug (やぐ)

これは重要だ、定数を使用しよう

queryKeyの指定には定数を使用する

これはinvalidateのポイントというよりqueryKeyの管理に通ずる話ですが、invalidateQueriesでクエリを指定する際には直接文字列で指定せずに定数で指定することを心がけていきたいです。

理由はシンプルでqueryKeyの管理を一元化して変更が起きた際の影響範囲を抑えるためです。当然invalidateQueriesに限らず、他のqueryKeyを指定するケース全てにおいて定数で指定することを徹底します。

しかしそれを作成するファクトリ関数(とそのprops)の書き方がやや難しそうだが、まぁ慣れるしかない

// 🕺 remove everything related
// to the todos feature
queryClient.removeQueries({
  queryKey: todoKeys.all
})

// 🚀 invalidate all the lists
queryClient.invalidateQueries({
  queryKey: todoKeys.lists()
})

// 🙌 prefetch a single todo
queryClient.prefetchQueries({
  queryKey: todoKeys.detail(id),
  queryFn: () => fetchTodo(id),
})

ん、??なんか初見のprefetchQueriesとやらがあるな

prefetchなるものができるらしい、一応メモ
https://tanstack.com/query/v4/docs/framework/react/guides/prefetching

tanstack routerのpathアクセスのbeforeLoadと使い分けせねばなぁ

そういえばmutate(UPDATE)で言うところのonMutateによるOptimistic Updateはあったな
それのGET版って感じか、多分
https://zenn.dev/taisei_13046/books/133e9995b6aadf/viewer/257b1a#onmutate

Yug (やぐ)Yug (やぐ)

Chapter 09 【まとめ】TanStack Queryを有効に扱うための設計

Yug (やぐ)Yug (やぐ)

これとか勉強になる

key.ts
export const postKeys = {
  all: ['posts'] as const,
  lists: () => [...postKeys.all, 'list'] as const,
  list: (filters: string) => [...postKeys.lists(), { filters }] as const,
  details: () => [...postKeys.all, 'detail'] as const,
  detail: (id: number) => [...postKeys.details(), id] as const,
} as const;

ふむふむ

service
 | └ post
 |   ├ function.ts # queryFn, mutationFnを管理
 |   ├ index.ts # useQuery, useMutationを呼ぶ
 |   ├ key.ts # queryKeyを管理
 |   ├ selector.ts # selectオプションに渡すデータ変換関数を管理
 |   └ type.ts # Request, Responseの型を管理
Yug (やぐ)Yug (やぐ)

たしかに。前述の通りだな

関数のReturn型を指定するというのは非常に重要なポイントです。ここではPromise<GetPostsResponse>が返却されることを指定しています。するとuseQueryが型を推論してくれ、明示的にレスポンスの型定義をGenericsで指定する必要がなくなります。

Yug (やぐ)Yug (やぐ)

多分これ、各プロパティにas const書く必要無いような?(可読性のためつけてるだけかな?)

key.ts
export const postKeys = {
  all: ['posts'] as const,
  lists: () => [...postKeys.all, 'list'] as const,
  list: (filters: string) => [...postKeys.lists(), { filters }] as const,
  details: () => [...postKeys.all, 'detail'] as const,
  detail: (id: number) => [...postKeys.details(), id] as const,
} as const;

ネストされたものも全部readonlyになるのがas constの仕様なので、全体(postKeys)にas constをつけた時点でもう十分かも
https://typescriptbook.jp/reference/values-types-variables/const-assertion

Yug (やぐ)Yug (やぐ)

fetcher関数だけではなくselectの関数でも返り値の型設定しておくことを忘れずに

import { Post } from "../../domain/post";
import { GetPostsResponse } from "./type";

export const getPostsSelector = (data: GetPostsResponse): Post[] => {
  return data.slice(0, 10);
};

ここでもfunction.tsと同様に返却される型を指定しましょう。すると、useQuery側がselectオプションによって返却される型を推論してくれます。

Yug (やぐ)Yug (やぐ)

domain/でも型を書いてservice/内のtype.tsでも型を書くの紛らわしいなぁ
(domainが何かよくわかっていないのでとりあえず全部type.tsに統一しちゃって良いかも)

Yug (やぐ)Yug (やぐ)

なるほど~UIとの分離のためにカスタムフック作成か

最後にindex.tsではこれら全ての関数を駆使してuseQuery・useMutationのCustom Hookを作成します。Dominikさんのブログの中でもUIとの関心を分離させるためにuseQuery・useMutationなどのAPIはCustom Hook化し、UIから直接呼ぶべきではないと書かれています。

import { useQuery } from "@tanstack/react-query";
import { postKeys } from "./key";
import { getPosts } from "./function";
import { getPostsSelector } from "./selector";

export const useGetPosts = () => {
  const { data, isPending, isError } = useQuery({
    queryKey: postKeys.lists(),
    queryFn: getPosts,
    select: getPostsSelector,
  });

  return {
    data,
    isPending,
    isError,
  };
};
Yug (やぐ)Yug (やぐ)

あーそういや忘れてた

もちろんTanStack QueryのAPIはuseQueryとuseMutation以外にも存在します。

TODO: UPDATE/DELETEがあるのか調べる

Yug (やぐ)Yug (やぐ)

使い方はわかった。あとはセットアップの方法とSuspenseとの組み合わせ方法がわかればいけそう

このスクラップは29日前にクローズされました
ログインするとコメントできます