非同期処理に疲れた方に、ReactQueryの処方箋
この記事について
本記事は、下記のReactQuery公式ドキュメントの内容をベースに、自分なりに噛み砕いてまとめたものになります。
ReactQuery公式ドキュメント
サンプルコードの一部は公式サイトから引用しています。
前置き
【呼称について】
ReactQueryはSolid
やVue
、Svelte
への対応を進めており、現在の正式な名称はTanStackQuery
になっています。
ReactQueryの方が耳馴染みのある方も多いため、この記事では、ReactQueryと呼ぶことにします。
【内部実装のイメージについて】
以下、「内部実装のイメージ」となっているアコーディオンの箇所は、ReactQueryの内部実装が実際にそうなっているということではなく、
「こういうふうなコードをイメージすると理解しやすそう」という意図で書いています。
基本的には読み飛ばしていただいても大丈夫な箇所です。
わかりにくい点などがありましたら、お気軽にコメントいただけると嬉しいです🙏
ではさっそく!
ReactQuery is 何?
ReactQueryは、APIとのやりとりを極限まで楽にしてくれるライブラリです。
ReactQueryを使ってAPIとのやりとりをどれくらい簡潔にできるのか理解するには、コードを覗いてみるのが一番です。
まず、ReactQueryを使っていない場合、
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を使って書くと、、、↓
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-thunk
やredux-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
を呼び出します。
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
を使います。
以下、useQuery
、useMutation
の順に詳しく見ていきます。
useQueryの使い方
useQueryのリファレンスを読むと、props
として渡せるoption
が27個もあって戸惑うかもしれません。
でも実は、onSuccess
, onError
, onSettled
, enabled
だけ理解していれば、9割のユースケースはカバーできます。
この4つのオプションを順に見ていきます。
useQueryのオプション: onSuccess, onError, onSettled,
それぞれ次のようなときなタイミングで実行される関数を設定できるオプションです。
- onSuccess: リクエストが成功したとき実行される関数
- onError: リクエストが失敗したときに実行される関数
- onSettled: リクエストが成功したときでも失敗したときでも最後に実行される関数
具体的には、次のような使い方が可能です。
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を叩きたくない」みたいなケースで使うのに向いています。
例えば、次のような場合です。
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
となっているので、アドミンでない場合には余計なリクエストを投げません。
内部実装のイメージ
次のようにenabled
がtrue
のときだけ、非同期処理が呼ばれます。
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
をオプションとして受け取ることができます。
具体的には、次のように使います。
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>
);
};
この例では、onSuccess
にhistory.push('/user-list')
を渡してあげているので、
postのリクエストが成功したタイミングで一覧画面に遷移します。
簡単ですね。
ここまでで、基本的な使い方の説明は終わりです!お疲れさまでした!
ReactQueryの使い方(中級編)
これまでの内容だけでも、快適非同期処理ライフのスタートは可能です。
しかし、ReactQuery
の真価を発揮したい人は、Query Key
とCache
の仕組みについて知っておくとさらに便利です。
中級編では、このQuery Key
とCache
の仕組みについて説明します。
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については、ここでは一旦忘れてください)
useQuery
はQuery Key
が変わったタイミングでAPIを叩きます。
Query Keyに正しい値をちゃんと設定してやらないと、データをうまく取得できません。
検索のページを例にとり、具体的にQuery Keyの使い方を見ていきます。
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
が変わったタイミングで非同期処理が走ります。
これを、
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 Key
にkeyword
を含めずに書いてしまうと、keyword
の値が変化しても非同期処理は走りません。
検索ワードを入力しても非同期処理は呼ばれず、沈黙のままになるので気をつけてください。
このようなミスを避けるために、リクエストのパラメータをすべてQuery Key
に含める運用にするのが良いと思います。
内部実装のイメージ
Reactに慣れている方は、次のようにQuery Key
はuseEffect
の依存配列の働きをすると捉えておくと理解しやすいです。
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
がない場合のユーザー体験です。
次に、Cache
がある場合のユーザー体験です。
両者を比較すると、同じAPIを叩いているのに、後者の方がキビキビ動いているように見えます。
具体的に何が違うのかというと、2番目のGIF画像では、一度投げたリクエストは瞬時に結果を表示できています。
これを実現するのがCache
です。
ReactQuery
はAPIから返ってきたデータをしばらくの間、Cache
という場所に保持してくれる機能を持っています。
同じリクエストをしたときには、Cache
に保持していたデータを取り出して使うことができます。
サーバーにデータを取得しに行くより、Cache
に保持していたデータを使う方がはるかに早いスピードでデータを表示できます。
この説明だけだとわかりにくいと思うので、次のコードを例にとってみます。
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ページ目に訪れたとき:APIからのレスポンスを待って画面を表示
- 「次へ⇨」を押して2ページ目に訪れたとき:APIからのレスポンスを待って画面を表示
- 「⇦前へ」2ページ目から1ページに戻ったとき:
Cache
に保持しているデータを取り出して、瞬時に画面を表示
という挙動になるため、キャッシュの仕組みがない場合より、動作が軽快になるのです。
Cacheの仕組み
Query Key is 何?のところで、
Query Keyは、大きく分けて次の2つの役割をするキーです。
【役割1】キーの値が変わったタイミングで、データを取得するためのキー
【役割2】キャッシュとのデータのやりとりを行うためのキー
(役割2については、ここでは一旦忘れてください)
と書いた箇所の飛ばした②の部分を説明していきます。
既に見たようにReactQuery
はCache
と呼ばれる一時保存場所に、APIから取得したデータをキープします。
ここではCache
を「キャビネット」、APIから取得したデータをその中に入れる「ファイル」と考えてみます。
このとき、ファイルに貼るラベルのようなものがないと、せっかく収納したデータも取り出し方がわからなくなってしまいます。
わからなくなるだけならまだましですが、誤ってファイルを取り出してきてしまうかもしれません。
この「ファイルに貼るラベル」の役割をするのが、先ほど出てきたQuery Key
です。
敢えて、Query Key
にhoge
といういいかげんな値を設定して、
ファイルに正しいラベルを貼っておかないとどうなるかを見てみます。
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">
</>
)
}
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']
というラベルを同じラベルで管理してしまったがために、エラーが発生します。
エラーが起こる具体的な流れは以下の通りです
- まずリンクを押下して
BookPage
からUniversitiesPage
にジャンプする。 - このとき
UniversitiesPage
のuseQuery
は、Query Key
の['hoge']
を参照して、「さっきのBookPage
と同じデータが使える!」と誤った判断をする。 - 誤った判断の結果、
ReactQuery
はUniversitiesPage
では全く使えない誤ったデータを渡してくる。 - 「これ思ってたデータとちゃうやんけ💢💢💢」
- エラー発生
このようなミスを防ぐためには、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
の状態をコントロールする方法を見ていきます。
例として、ユーザープロフィールページに、ユーザーのプロフィールを変更できる機能をつけてみます。
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の処方箋
というタイトルで、さらに実践的な使い方を見ていきたいと思います。
最後までお読みいただきありがとうございました🙇♂️
それでは、良き非同期処理ライフを!
Discussion