💊

非同期処理に疲れた方に、ReactQueryの処方箋

2022/08/14に公開約18,000字

この記事について

本記事は、下記のReactQuery公式ドキュメントの内容をベースに、自分なりに噛み砕いてまとめたものになります。

ReactQuery公式ドキュメント

https://tanstack.com/query/v4

サンプルコードの一部は公式サイトから引用しています。

前置き

【呼称について】
ReactQueryはSolidVueSvelteへの対応を進めており、現在の正式な名称はTanStackQueryになっています。
ReactQueryの方が耳馴染みのある方も多いため、この記事では、ReactQueryと呼ぶことにします。

【内部実装のイメージについて】
以下、「内部実装のイメージ」となっているアコーディオンの箇所は、ReactQueryの内部実装が実際にそうなっているということではなく、
「こういうふうなコードをイメージすると理解しやすそう」という意図で書いています。
基本的には読み飛ばしていただいても大丈夫な箇所です。

わかりにくい点などがありましたら、お気軽にコメントいただけると嬉しいです🙏
ではさっそく!

ReactQuery is 何?

ReactQueryは、APIとのやりとりを極限まで楽にしてくれるライブラリです。
ReactQueryを使ってAPIとのやりとりをどれくらい簡潔にできるのか理解するには、コードを覗いてみるのが一番です。
まず、ReactQueryを使っていない場合、

NonReactQuery.jsx
import axios from "axios"

const  WithoutReactQuery = () => {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState()
  const [data, setData]= useState()

  useEffect(() => {
    setLoading(true)
    const asyncFn = () => {
      axios.get("https://www.googleapis.com/books/v1/volumes?q=vscode").then(res => {
        setData(data)
        setLoading(false)
      }).catch((err) => {
        setError(err.message)
        setLoading(false)
      })
    }
    asyncFn()
  }, [])

  if(isLoading){
    return "loading..."
  }

   if(error){
    return "error!"
  }

  return  // 省略
}

↑だいたいこんな感じになるかと思います。
これをReactQueryを使って書くと、、、↓

WithReactQuery.jsx
import axios from "axios"
import { useQuery } from '@tanstack/react-query'

const  WithReactQuery = () => {
  const { isLoading, error, data } = useQuery(['repoData'], () => axios.get('https://api.github.com/repos/tannerlinsley/react-query'))

  if(isLoading){
    return "loading..."
  }

   if(error){
    return "error!"
  }

  return  // 省略
}

17行あったデータ取得に関わるコードが、

const { isLoading, error, data } = useQuery(['repoData'], () => axios.get('https://api.github.com/repos/tannerlinsley/react-query'))

というわずか1行で簡潔に書けました。

このようにReactQueryを使うと、スッキリ快適な非同期処理生活をはじめられます。
これは最も基本的な使い方の1つであって、ReactQueryの機能の一部に過ぎません。
他の使い方も順次、ご紹介していきます。

ReactQueryの処方箋

無限に続く非同期処理のスパゲッティコードによる胃痛・胃部不快感に良く効きます。
また、redux-thunkredux-sagaを使ったデータ取得にお疲れの方も、ReactQueryを使うことで気力を取り戻せます。
その他、

  • 開発しやすさとUXのどちらも犠牲にしたくない方
  • Reactの宣言的な設計に共感し、データ取得も宣言的にやりたい方

にもおすすめのライブラリです。

ReactQueryには、次のような特徴があります。

  • APIから取得してきたデータ状態管理
  • 開発者とユーザーの両方のエクスペリエンスを改善する
  • 開発効率を上げるDeveloperToolsつき
  • RESTだけでなく、GraphQLも対応
  • SSR、Next.js対応

さっそくインストールの方法から見ていきます。

ReactQueryのはじめかた

プロジェクトのルートディレクトリで

$ npm i @tanstack/react-query
# or
$ yarn add @tanstack/react-query

を叩いてReactQueryをインストールします。

次に、Reactのルートに近い部分、例えばApp.jsxで、次のようにProviderを呼び出します。

App.jsx
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'

const queryClient = new QueryClient()

export const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

あとは、先ほどのようにAPIを叩いてあげるだけです!

ReactQueryの使い方(初級編)

ここまででどういったライブラリなのか、雰囲気は伝わったかと思います。
ここからはもう少し具体的にReactQueryの使い方について説明していきます。

ReactQueryには既出のuseQueryというデータ取得用の機能以外にも、useMutationというデータ更新用の機能があります。
基本的には、GETのときにuseQueryを、PUT・POST・DELETEのときにuseMutationを使います。
以下、useQueryuseMutationの順に詳しく見ていきます。

useQueryの使い方

useQueryのリファレンスを読むと、propsとして渡せるoptionが27個もあって戸惑うかもしれません。
でも実は、onSuccess, onError, onSettled, enabledだけ理解していれば、9割のユースケースはカバーできます。
この4つのオプションを順に見ていきます。

useQueryのオプション: onSuccess, onError, onSettled,

それぞれ次のようなときなタイミングで実行される関数を設定できるオプションです。

  • onSuccess: リクエストが成功したとき実行される関数
  • onError: リクエストが失敗したときに実行される関数
  • onSettled: リクエストが成功したときでも失敗したときでも最後に実行される関数

具体的には、次のような使い方が可能です。

UserProfilePage.jsx
import axios  from "axios";
import { useToasts } from "react-toast-notifications"
import { useQuery } from '@tanstack/react-query'

const UserProfilePage = () => {
  const { addToast } = useToasts()
  const { data } = useQuery(['profileData'], () => axios.get('https://your-api-endpoint/user/profile').then(res => res.data), {
    onSuccess: (data) => addToast(`こんにちは、${data.name}さん!`, { appearance: "success" })
    onError: (error) => addToast(`データ取得に失敗しました:${error.message}`, { appearance: "error" })
  })

  return <ProfileTemplate data={data} />
}
内部処理のイメージ

非同期処理に慣れている方は、onSuccess, onError, onSettledがそれぞれ、
then, catch, finallyに対応していると考えると理解しやすいです。

const useQuery = (_, asyncFn, options) => {
  useEffect(() => {
    asyncFn().then(data => {
      options.onSuccess(data)
    }).catch(error => {
      options.onError(error)
    }).finally(() => {
      options.onSettled()
    }
  }, [asyncFn, options])

useQueryのオプション: enabled

useQueryは、enabledの値がtrueになっているときだけ、リクエストを投げます。
「特定の条件に合致するときはAPIを叩きたくない」みたいなケースで使うのに向いています。
例えば、次のような場合です。

UserProfilePage.jsx
import axios  from "axios";
import { useAuthState } from "react-firebase-hooks/auth";
import { useQuery } from '@tanstack/react-query'

const UserProfilePage = () => {

  const [user, loading, error] = useAuthState(auth);
  const { data } = useQuery(['profileData'], () => axios.get('https://your-api-endpoint/user/profile').then(res => res.data), {
    enabled: user.isAdmin
  })

  if(!user.isAdmin){
    return <p>⚠︎管理者以外の方は、プロフィールを閲覧できません</p>
  }

  return <ProfileTemplate data={data} />
}

この例では、enabled: user.isAdminとなっているので、アドミンでない場合には余計なリクエストを投げません。

内部実装のイメージ

次のようにenabledtrueのときだけ、非同期処理が呼ばれます。

const useQuery = (_, asyncFn, options) => {
  useEffect(() => {
+   if(options.enabled) {
      asyncFn().then(data => {
        options.onSuccess(data)
      }).catch(error => {
        options.onError(error)
      }).finally(() => {
        options.onSettled()
      }
    }
  }, [asyncFn, options])

useMutationの使い方

useMutationはデータ更新用のHooksですが、使い方はだいたいuseQueryと同じです。
ただし、useQueryのように1番目の引数に配列を渡してあげる必要はありません。

useMutationのオプション: onSuccess, onError, onSettled,

useMutationは、useQueryと同じようにonSuccess, onError, onSettledをオプションとして受け取ることができます。
具体的には、次のように使います。

UserInvitePage.jsx
import axios from 'axios';
import React, { useState } from 'react';
import { useToasts } from 'react-toast-notifications';
import { useHistory } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';

const UserInvitePage = () => {
  const { addToast } = useToasts();
  const history = useHistory();
  const [email, setEmail] = useState()

  const { isLoading, mutate } = useMutation(
    () => axios.post('https://your-api-endpoint/user/invite', { email }),
    {
      onSuccess: () => {
        history.push('/user-list')
      },
    },
  );
  const handleSubmit = (e) => {
    e.preventDefault();
    mutate()
  }

  if (isLoading) {
    return <p>招待メールを送信中です...</p>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <button type="submit">招待</button>
    </form>
  );
};

この例では、onSuccesshistory.push('/user-list')を渡してあげているので、
postのリクエストが成功したタイミングで一覧画面に遷移します。
簡単ですね。

ここまでで、基本的な使い方の説明は終わりです!お疲れさまでした!

ReactQueryの使い方(中級編)

これまでの内容だけでも、快適非同期処理ライフのスタートは可能です。
しかし、ReactQueryの真価を発揮したい人は、Query KeyCacheの仕組みについて知っておくとさらに便利です。
中級編では、このQuery KeyCacheの仕組みについて説明します。

Query Key is 何?

すでに出てきた、useQueryの使用例では、

const { data } = useQuery(["profileData"], () => axios.get('https://your-api-endpoint/user/profile'),

といった形で、一つ目の引数で、配列(ここでは["profileData"])を渡していました。
既に気になっていた方かもいらっしゃるかもしれませんが、これがQuery Keyです。

Query Keyは、大きく分けて次の2つの役割をするキーです。
【役割1】キーの値が変わったタイミングで、データを取得するためのキー
【役割2】キャッシュとのデータのやりとりを行うためのキー
(役割2については、ここでは一旦忘れてください)

useQueryQuery Keyが変わったタイミングでAPIを叩きます。
Query Keyに正しい値をちゃんと設定してやらないと、データをうまく取得できません。
検索のページを例にとり、具体的にQuery Keyの使い方を見ていきます。

BookSearchPage.jsx(良い例)
import axios from 'axios';
import React, { useState } from "react"
import { useQuery } from "@tanstack/react-query"

const BookSearchPage = () => {
  const [keyword, setKeyword] = useState()
  const { data }  = useQuery(
    ['bookData', keyword],
    () => axios.get(`https://www.googleapis.com/books/v1/volumes?q=search+${keyword}`).then(res => res.data),
    { enabled: keyword !== undefined }
  )

  return (
    <>
      <input placeholder="検索ワードを入力してください" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <div>
        <h1>検索ワードにヒットしたデータ:</h1>
        {data && data.items.map((item) => <p>{item.id}</p>)}
      </div>
    </>
  )
}

上の例では既出のenabledのオプションを使って、{ enabled: keyword !== undefined }としているので、ユーザーが何も検索ワードを入力していないときは余計なリクエストを投げません。

ユーザーが検索ワードを入力すると、keywordの値が変わります。
Query Keyにはkeywordを渡しあげているので、keywordが変わったタイミングで非同期処理が走ります。

これを、

BookSearchPage.jsx(悪い例)
const { data, isLoading }  = useQuery(
  ['bookData'],
  () => axios.get(`https://www.googleapis.com/books/v1/volumes?q=search+${keyword}`).then(res => res.data),
  { enabled: keyword !== undefined }
)

のようにQuery Keykeywordを含めずに書いてしまうと、keywordの値が変化しても非同期処理は走りません。
検索ワードを入力しても非同期処理は呼ばれず、沈黙のままになるので気をつけてください。

このようなミスを避けるために、リクエストのパラメータをすべてQuery Keyに含める運用にするのが良いと思います。

内部実装のイメージ

Reactに慣れている方は、次のようにQuery KeyuseEffectの依存配列の働きをすると捉えておくと理解しやすいです。
useEffectの依存配列に嘘をついてはいけないように、Query Keyにも嘘をつかないようにしてください。

const useQuery = (queryKey, asyncFn, options) => {
  useEffect(() => {
    if(options.enabled) {
      asyncFn().then(data => {
        options.onSuccess(data)
      }).catch(error => {
        options.onError(error)
      }).finally(() => {
        options.onSettled()
      }
    }
  }, [...queryKey, asyncFn, options])

Cache is 何?

ReactQueryのキャッシュがどんなものなのか説明する前に、Cacheがあると何が嬉しいのか説明します。
まず、Cacheがない場合のユーザー体験です。

slow

次に、Cacheがある場合のユーザー体験です。

fast

両者を比較すると、同じAPIを叩いているのに、後者の方がキビキビ動いているように見えます。
具体的に何が違うのかというと、2番目のGIF画像では、一度投げたリクエストは瞬時に結果を表示できています。

これを実現するのがCacheです。

ReactQueryはAPIから返ってきたデータをしばらくの間、Cacheという場所に保持してくれる機能を持っています。
同じリクエストをしたときには、Cacheに保持していたデータを取り出して使うことができます。
サーバーにデータを取得しに行くより、Cacheに保持していたデータを使う方がはるかに早いスピードでデータを表示できます。

この説明だけだとわかりにくいと思うので、次のコードを例にとってみます。

UniversitiesPage.jsx
import React, { useState } from "react"
import { useQuery } from "@tanstack/react-query"

const getUniversities= (page) =>
  fetch("https://your-api-endpoint/universities?page=" + page).then(res => res.json())

const UniversitiesPage= () => {
  const [page, setPage] = useState(1);
  const { data } = useQuery(
    ["universitiesData", page],
    () => getUniversities(page),
  );

  if(isLoading){
    return <p>loading...</p>
  }

  return (
    <>
      {data && data.map((university) => (
        <p key={university.id}>{university.name}</p>
      ))}
      <div>
        <button onClick={() => setPage(page - 1)}>⇦前へ</button>
        <button onClick={() => setPage(page + 1)}>次へ⇨</button>
      </div>
    </>
  );
};

この例では、

  1. 最初に1ページ目に訪れたとき:APIからのレスポンスを待って画面を表示
  2. 「次へ⇨」を押して2ページ目に訪れたとき:APIからのレスポンスを待って画面を表示
  3. 「⇦前へ」2ページ目から1ページに戻ったとき:Cacheに保持しているデータを取り出して、瞬時に画面を表示

という挙動になるため、キャッシュの仕組みがない場合より、動作が軽快になるのです。

Cacheの仕組み

Query Key is 何?のところで、

Query Keyは、大きく分けて次の2つの役割をするキーです。
【役割1】キーの値が変わったタイミングで、データを取得するためのキー
【役割2】キャッシュとのデータのやりとりを行うためのキー
(役割2については、ここでは一旦忘れてください)

と書いた箇所の飛ばした②の部分を説明していきます。

既に見たようにReactQueryCacheと呼ばれる一時保存場所に、APIから取得したデータをキープします。

ここではCacheを「キャビネット」、APIから取得したデータをその中に入れる「ファイル」と考えてみます。
このとき、ファイルに貼るラベルのようなものがないと、せっかく収納したデータも取り出し方がわからなくなってしまいます。
わからなくなるだけならまだましですが、誤ってファイルを取り出してきてしまうかもしれません。

この「ファイルに貼るラベル」の役割をするのが、先ほど出てきたQuery Keyです。
敢えて、Query Keyhogeといういいかげんな値を設定して、
ファイルに正しいラベルを貼っておかないとどうなるかを見てみます。

BookPage.jsx
import axios from 'axios';
import React, { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Link } from "react-router-dom"

const BookPage = () => {
  const { data }  = useQuery(
    ['hoge'],
    () => axios.get(`https://www.googleapis.com/books/v1/volumes?q=search+`).then(res => res.data),
  )

  return (
    <>
      <div>
        {data && data.items.map((item) => <p key={item.id}>{item.id}</p>)}
      </div>
      <Link to="/universities">
    </>
  )
}
UniversitiesPage.jsx
import React, { useState } from "react"
import { useQuery } from "@tanstack/react-query"

const getUniversities= (page: number) =>
  fetch("https://your-api-endpoint/universities?page=" + page).then(res => res.json())

const UniversitiesPage = () => {
  const [page, setPage] = useState(1);
  const { data } = useQuery(
    ['hoge']
    () => getUniversities(page),
  );

  return (
    <>
      {data && data.map((university) => (
        <p key={university.id}>{university.name}</p>
      ))}
    </>
  );
};

ここでは、本のデータと大学のデータという全く別のデータを、
同じ['hoge']というラベルを同じラベルで管理してしまったがために、エラーが発生します。

エラーが起こる具体的な流れは以下の通りです

  1. まずリンクを押下してBookPageからUniversitiesPageにジャンプする。
  2. このときUniversitiesPageuseQueryは、Query Key['hoge']を参照して、「さっきのBookPageと同じデータが使える!」と誤った判断をする。
  3. 誤った判断の結果、ReactQueryUniversitiesPageでは全く使えない誤ったデータを渡してくる。
  4. 「これ思ってたデータとちゃうやんけ💢💢💢」
  5. エラー発生

このようなミスを防ぐためには、booksなら["books"]、universitiesなら["universities"]にするなど、エンドポイントに1対1対応した一意のキーを設定しておくことが大切です

内部実装のイメージ

JavaScriptに慣れている方は、CacheはただのMap型のグローバル変数だと捉えておくとわかりやすいと思います。
次のようなイメージです。

const cache = new Map()

const useQuery = (queryKey, asyncFn, options) => {
  const [data, setData] = useState()
  useEffect(() => {
+   const cachedData = cache.get(JSON.stringify(queryKey))
+   if(cachedData){
+     setData(cachedData)
+   }
    if(options.enabled) {
      asyncFn().then(data => {
+       cache.set(JSON.stringify(queryKey), data)
        options.onSuccess(data)
      }).catch(error => {
        options.onError(error)
      }).finally(() => {
        options.onSettled()
      }
    }
  }, [...queryKey, asyncFn, options])

グローバル変数というとちょっと語弊があるかもですが、本当はReactのレンダリングツリーにおけるグローバル変数といった感じで、reduxにおけるstoreのようなものです。

また、細かい話なので上記の処理では省いていますが、実際にはqueryKeyにobjectが渡された場合は、

const ordered = Object.keys(objOfQueryKey).sort().reduce(
  (obj, key) => {
    obj[key] = unordered[key];
    return obj;
  },
  {}
);
cache.set(JSON.stringify(ordered), data)

みたいな感じに、整列された後にJSON.stringifyされて、Cacheのキーとなります。
何が言いたいかというと、

["hoge", { hoge: 1, fuga: 2}]["hoge", { fuga: 2, hoge: 1}]

とあった場合、①と②は完全に同じキーとして扱われるので注意してください。

Cacheの状態をコントロールする方法

最後に、useQueryClientを使って、Cacheの状態をコントロールする方法を見ていきます。
例として、ユーザープロフィールページに、ユーザーのプロフィールを変更できる機能をつけてみます。

UserProfilePage.jsx
import axios  from "axios";
import React, { useState } from "react"
import { useToasts } from "react-toast-notifications"
import { useQuery, useMutation } from '@tanstack/react-query'

const UserProfilePage = () => {
  const { data, isLoading } = useQuery(['profileData'], () => axios.get('https://your-api-endpoint/user/profile').then(res => res.data))

  if(isLoading){
    return <p>loading...</p>
  }

  return (
    <>
      <h1>こんにちは、{data.username}さん!</h1>
      <UserProfileForm data={data} />
    </>
  )
}

const UserProfileForm = (data) => {
  const { addToast } = useToasts()
  const [username, setUsername] = useState(data.username)
  const { mutate } = useMutation(
    () => axios.put('https://your-api-endpoint/user/profile', { username }),
    { onSuccess: () => addToast("ユーザー名を変更しました", { appearance: "success" })}
  )
  const handleSubmit = (e) => {
    e.preventDefault()
    mutate()
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={username} onChange={e => setUsername(e.target.value)}/>
      <button type="submit">保存</button>
    </form>
  )
}

これは、何か入力して保存ボタンを押すと、ユーザー名が更新されるコードです。
良い感じにできてそうにも見えますが、このままだと正常にユーザー名の更新が完了し、
画面に「ユーザー名を更新しました」というメッセージが表示されたときでも、
<h1>こんにちは、{data.username}さん!</h1>のところのデータは古いままになります。

なのでできれば、「データ更新されたので、そのキャッシュは古くなりました」というのをちゃんとCacheに伝えてあげたいです。
そうすれば、後は勝手にuseQueryが最新のデータを取得しようとリクエストを投げてくれます。

このようにCacheの状態をコントロールしたい、またはCacheにアクセスしたい、まさにそんなときに使うのがuseQueryClientです。
useQueryClientを呼び出すと、queryClientという値が返ってきます。
queryClientは、Cacheに保持されているデータが最新のものをではなくなったことを伝えるためのinvalidateQueriesというメソッドを持っています。
今回はそれを使ってみます。

import {
  useQuery,
  useMutation
+ useQueryClient
} from '@tanstack/react-query'

const UserProfileForm = (data) => {
  const { addToast } = useToasts()
  const [username, setUsername] = useState(data.username)

+ const queryClient = useQueryClient()
  const { mutate } = useMutation(
    () => axios.put('https://your-api-endpoint/user/profile', { username }),
    { onSuccess: () => {
        addToast("ユーザー名を変更しました", { appearance: "success" })
+       queryClient.invalidateQueries(["profileData"])
      }
    }
  )

  (省略)
}

ここでは、queryClient.invalidateQueries(["profileData"])の1行が
「profileDataはもう古くなったので、profileDataを使うときには新しいデータを取得してきてください」というのをCacheに伝えています。
useQuery(["profileData"], (...))の箇所が["profileData"]のCacheの状態を監視しているので、古くなったことを検知したuseQueryがすぐにデータを再取得しに行きます。

これで更新された名前が表示されるようになりました!

補足)もっと簡単なデータ再取得の方法

今回は、useQueryClientをマスターするために、敢えてちょっと小難しいデータを再取得を行いました。
でも実際はrefetchを使うと、もっと簡単にできます。
以下の通りです。

const { data, refetch } = useQuery(['profileData'], () => axios.get('https://your-api-endpoint/user/profile').then(res => res.data))
const { mutate } = useMutation(
  () => axios.put('https://your-api-endpoint/user/profile', { username }),
  { onSuccess: () => refetch() }
)

refetchを使うと確かに楽ですが、
invalidateQueriesじゃないと対応できないケースもあります。
理想的には両方覚えておいて、使い分けられるようにするのがオススメです。

まとめ

今回は、「だいたいこれくらいわかっていればReactQueryを使い始められる」というのをゴールにして書きました。
正確さよりもわかりやすさを重視したので、厳密にいうと正しくない部分は多々あるかもしれませんが、大きく間違っている部分があればご指摘いただけると幸いです。

次回は、 続・ReactQueryの処方箋というタイトルで、さらに実践的な使い方を見ていきたいと思います。

最後までお読みいただきありがとうございました🙇‍♂️

それでは、良き非同期処理ライフを!

GitHubで編集を提案

Discussion

ログインするとコメントできます