Open12

TanStack Query の実践的な内容が書かれた TkDodo 氏の記事を読んでいく

つねみ@tocomiつねみ@tocomi

#1 Practical React Query

https://tkdodo.eu/blog/practical-react-query

TkDodo 氏は GraphQL の Apollo Client が流行ったときに redux が取って代わられると周りが言っているのを聞いて、「なんでデータ取得ライブラリがステートマネージャを置き換えるのか?」と思っていたらしい。
つまり、Apollo をただのデータ取得ライブラリと勘違いしていた。
Apollo はデータ取得だけでなくデータのキャッシュも行うと理解したとき、サーバーの状態をクライアントにあるように扱えるということがわかり、redux が取って代わられるというのも理解できたとのこと。

自分も最初 Tanstack Query をただのデータ取得ライブラリと思ってたからわかりみが深い。

このドキュメントは Tanstack Query の紹介というよりは、これまでの TkDodo 氏の取り組みから学んだ実践的な Tips を共有するらしい。

タブを切り替えたときとか、開発者ツールからメインウィンドウにフォーカスを戻したときに refetch が走るけど、あれは refetchOnWindowFocus というものらしい。
デフォルトでは ON になっている。
https://tanstack.com/query/v5/docs/react/guides/window-focus-refetching

v5 で改良されるみたい。開発者ツールとのフォーカス切り替えでは refetch は走らなくなる模様。結構気になっちゃうから地味に嬉しい。
https://github.com/TanStack/query/pull/4805

queryKey は useEffect の deps みたいなイメージで使う。key が変わったら refetch が走ってくれるから。

queryKey の切り替えで望まない loading ができてしまう場合は、initialData を使って loading 中の表示をコントロールすることで UX を改善できる。(あまり理解できている自信ない)

useQuery で取得したデータをローカルステートに保持してしまうと useQuery の利便性の一部が失われてしまうので避けるべき。ただし、初期値を取得して保持しておくなどの用途ではその限りではない。その場合 staleTime での制御は必要。

つねみ@tocomiつねみ@tocomi

#2 React Query Data Transformations

https://tkdodo.eu/blog/react-query-data-transformations

この回は日本語訳がありました!contributer の方々ありがとうございますmm

TanStack Query を使う場合、データ変換はどこで行うべきか?

バックエンドで変換ができればフロントエンドは何もしなくて良くなるけど、必ずしも実現できることではない。

queryFn で変換するとコロケーションの文脈ではいいが、TanStack Query の恩恵は受けられない。

useQuery を使うカスタムフック内で変換処理を行うこともできる。この場合、必要以上に変換処理が走らないようにメモ化することが推奨される。deps に含める変数を絞ることにも注意。data が undefined である可能性を考慮する必要がある。

select オプションを使用すると、undefined を考慮することなく、またデータの一部のみを subscribe する形でデータを変換することができる。こちらもインラインの関数だとレンダリングごとに変換処理が走るので、useCallback を使ったりコンポーネントの外で関数を定義するなどの工夫が select 関数のコストによっては必要。

つねみ@tocomiつねみ@tocomi

#3 React Query Render Optimizations

https://tkdodo.eu/blog/react-query-render-optimizations

章の内容に入る前の留意事項

不要な再レンダリングを防ぐことは多くの人が関心を持つトピックだが、レンダリングの最適化が重要になることは実際にはそこまでないということを言っている。
再レンダリングはアプリを最新の状態に保つために必要なことで、「必要なのにレンダリングが発生しない」よりも「不要な再レンダリング」を選ぶよと。
関連して、以下の記事が紹介されている。

https://kentcdodds.com/blog/fix-the-slow-render-before-you-fix-the-re-render
https://reacttraining.com/blog/react-inline-functions-and-performance

notifyOnChangeProps

再レンダリングのために変更を監視するフィールドを指定できる。
data を指定すれば、data に変更があるときだけ再レンダリングが走る。
デフォルトだと、例えば data が変わらなくても isFetching が変わったタイミングで再レンダリングが走るということがある。
ただし、この設定を使うと変更を検知したい状態でも検知できないというバグの温床になりうる。

Tracked Queries

notifyOnChangeProps に 'tracked' を指定すると、レンダリングで利用されるフィールドのみを監視するようになる。
便利なオプションだが監視コストが発生するのと制限があるので opt-in 方式になっている。
オブジェクト展開をしてしまうとすべてのフィールドを監視するようになってしまうので要注意。

// 🚨 will track all fields
const { isLoading, ...queryInfo } = useQuery(...)

// ✅ this is totally fine
const { isLoading, data } = useQuery(...)

Structural sharing

(理解があまりできていない)

古いステートと新しいステートを比較して、差分がない部分は更新しないようにしている。
これを実現しているのが Structual sharing という機構。
ステートのサイズが大きくなるとパフォーマンスのボトルネックになりうるので、クエリで structuralSharing: false を指定することでオプトアウトできる。

つねみ@tocomiつねみ@tocomi

#4 Status Checks in React Query

https://tkdodo.eu/blog/status-checks-in-react-query

ステートマシンを参考に様々なステータスを boolean で公開している。

status 状態
success クエリーは成功してdataがある
error クエリーは機能せず、errorがセットされる
loading クエリーはデータを持たず、現在初回のloading中である

isFecthing は付加的なパラメータであり、ステートマシンの外にある。

TanStack Query はデータ取得に失敗したときの再試行を積極的に行う。
refetchOnMount refetchOnWindowFocus refetchOnReconnect 等はデータを最新に保つのに役立つが、UX の観点では時に混乱をもたらすかもしれない。

TanStack Query では、データのバックグラウンド更新に失敗したとき、開発者は古いデータを表示するかエラーを表示するか、はたまたどちらも表示するか選択できるようになっている。
これに正解はないので、扱っているデータや UX を考えて組み立てる必要がある。

つねみ@tocomiつねみ@tocomi

#5 Testing React Query

https://tkdodo.eu/blog/testing-react-query

テストごとに QueryClientProvider を作ることを推奨。テストを並列実行したときにキャッシュの状態などで予期せぬ動作をすることを防ぐため。

hooks のテストであれば react-hooks-testing-library が有用。renderHook の wrapper の中で QueryClient を作成する。

const createWrapper = () => {
  // ✅ creates a new QueryClient for each test
  const queryClient = new QueryClient()
  return ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

test('my first test', async () => {
  const { result } = renderHook(() => useCustomHook(), {
    wrapper: createWrapper(),
  })
})

component のテストであれば react-testing-library を使って同じように wrapper で QueryClient を作成する。

TanStack Query で実際にどのようにテストを書いているか

データ取得が失敗するテストを書く場合は必要に応じて retry を無効にしておく。

非同期の処理なので、成功するまで待つ必要がある。

// ✅ return a Promise via expect to waitFor
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toBeDefined()
つねみ@tocomiつねみ@tocomi

#6 React Query and TypeScript

https://tkdodo.eu/blog/react-query-and-type-script

useQuery のジェネリクス

export function useQuery<
  /** queryFn が返す型 */
  TQueryFnData = unknown,
  /** queryFn から予期される Error の型 */
  TError = unknown,
  /** select を使った時に select が返す型 */
  TData = TQueryFnData,
  /** queryKey の型 */
  TQueryKey extends QueryKey = QueryKey
>

基本的には型を明示的に指定せずに推論させることを推奨。queryFn の戻り値に型がついていることが条件。

Error の型

error は指定しなければ unknown。
絞り込みをしたい場合は、instanceof Error が使える。

つねみ@tocomiつねみ@tocomi

#8 Effective React Query Keys

https://tkdodo.eu/blog/effective-react-query-keys

データの再取得

例えば何かをトリガーにデータを再取得したい場合は、そのトリガーで変更される state を queryKey に入れておけばそれだけで自動的に再取得される。

function Component() {
  const [filters, setFilters] = React.useState()
  const { data } = useQuery({
    queryKey: ['todos', filters],
    queryFn: () => fetchTodos(filters),
  })

  // ✅ set local state and let it drive the query
  return <Filters onApply={setFilters} />
}

コロケーション

https://kentcdodds.com/blog/colocation

/src/utils/queryKeys.ts にグローバルにキーを集約するよりも、機能ディレクトリごとにキーを集約しよう。

ファクトリー

キーを集約するためにファクトリーを作ることもできる。

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,
}
つねみ@tocomiつねみ@tocomi

#9 Placeholder and Initial Data in React Query

https://tkdodo.eu/blog/placeholder-and-initial-data-in-react-query

UX 向上にまつわる内容。
初期読み込みの体験を向上するために、PlaceholderData と InitialData の似て非なる 2 つのデータがある。

共通点

どちらもキャッシュデータを事前に注入しておくのに使う。
これらのデータが渡されるとステータスは loading にならず即 success になる。

相違点

InitialData はキャッシュレベルで動作して、PlaceholderData はオブザーバーレベルで動作する。

キャッシュはグローバルなので、InitialData は 1 つのクエリに対して 1 つだけ存在する。
オブザーバーは useQuery ごとに作られるので、PlaceholderData はクエリごとに存在する。キャッシュではなく成功するまでのフェイクと捉えるのがよい。
isPlaceholderData フラグを使うことで、データの取得中のフェイクであることを示すことができる。

使い分け

使う人次第。
筆者は、別のクエリからのデータを事前に注入しておく場合は InitialData、それ以外の場合は PlaceholderData を使うのが好みらしい。

つねみ@tocomiつねみ@tocomi

#10 React Query as a State Manager

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

React Query はデータ取得ライブラリではない。
実際にデータを取得するのは fetch, axios, ky とかがやっている。
じゃあ React Query は何をしているのか?

RQ は非同期状態管理ライブラリである。
queryKey でクエリを一意に判別するため、異なるコンポーネントからクエリを呼び出してもキーが同一であれば一度だけ取得が行われて同じデータを取得できる。

非同期の状態管理をする場合、取得した後のデータが正確かどうかが重要になる。
たとえば twitter の投稿を取得すると、すぐにそのデータは最新ではなくなる可能性が高い。
一方で日時で更新される為替データに関しては、一度取得すればしばらくそのデータは最新になる。

Stale While Revalidate

React Query が利用するキャッシュメカニズム。
特有のものではなく昔からあるもの。
データがないよりは古いデータのほうがまだ良いという原則。

Smart refetches

コンポーネントの再レンダリングの度にキャッシュを更新していてはコストが高いので、効率的にキャッシュを更新するための機構がある。

refetchOnMount

コンポーネントがマウントされる度に revalidate する。

refetchOnWindowFocus

ブラウザのタブにフォーカスが当たる度に revalidate する。
開発中は頻繁にタブ移動するため頻度が高いと思われがちだが、実際のユースケースで考えてみると、メーラーやツイッターからアプリに戻ってきたときにキャッシュの更新を行うことは理にかなっている。

refetchOnReconnect

ネットワークが再接続されたタイミングはキャッシュ更新を行うタイミングとして望ましい。

invalidateQueries

queryClient.invalidateQueries を使うことで任意のタイミングでキャッシュの更新を走らせられる。
mutation の実行後に有用。