はじめに
NTTテクノクロスの上原です。
この記事はNTTテクノクロスアドベントカレンダー2021 24日目の記事です。昨日は@watanyさんの記事」、明日は@j-yamaさんの記事になります。
自分は過去のAdvent CalenderではReact関連では(React Meets Grails、Grails React Scaffold、Redux Saga、Gatsby、React Spring、AirTableのAPPSをReactで作ろう)などを書いて来ましたが、結局1年ぶりに記事を書いています。もっともっとアウトプットしないといけないですね。
それはともかく、今回は「状態管理ライブラリとしてのReact Query」を紹介し、「useState感覚でクライアント状態管理する手法」 を提案してみたいと思います。何はともあれその手法とやらを先にみたい方はこちらのuseQStateサンプルへどうぞ。
対象読者はReact開発者で状態管理やReact Queryに興味がある人です。
これこれ、こういうので良いんだよ
React Queryは人気がとても高い「Reactにおける非同期データのためのフェッチ、キャッシュ、更新」のライブラリであり、「サーバ状態管理」のライブラリであると説明されます。実際、APIを呼びだして扱うときの手際の良さには秀逸なところがあります。 「まだuseEffectで消耗してるの?」 てなもんです。
さらに、React Queryを使っていると、これはある種のReactアプリのアプリケーションアーキテクチャ、アプリ設計指針、プログラミングモデルを提供しているのだなということがわかってきて、これはおおげさに言えば、革新的なものです。それはどういうものかというと
サーバから得たデータをpropsで受け渡すことなんかいらん!! そこで取れ!
ですね。あるサーバのデータについて、関連の深い特定のコンポーネントで取って、それをpropsで配分していく、ということはReact Queryではあんまりしません。「いつどこでデータを取得するか」を「特定のコンポーネントで実行する」ように意識するのではなく、必要とするそれぞれのコンポーネントで自分でuseQuery Hooksを取り、hookの一連呼び出しを通じてデータ間の依存性を依存グラフとして宣言的に配線していくイメージです。これが可能なのは、キャッシュを大前提にしており、データ更新が適宜になされることが期待できるからです。ちなみに個々のデータはキャッシュキーで識別・特定し、かつ依存性を指定します。依存性に従って依存クエリが発火します。つまり
データ間の依存性をコンポーネントの階層関係と独立に、依存グラフとして宣言的に表現し、更新伝搬を実現する。
というモデルです。とはいえ、useQueryで直接データを取ることはコンポーネントがReact Queryに依存することでもあるので、propsで受け渡すことが一概に悪いという話では当然なくてトレードオフです。
もうちょっと、深刻なはなしとして
Reduxの全盛期は過ぎてしまった気がしますが、Reduxが解決しようとしていた問題が無くなったわけではありません。アプリケーションの持つすべての状態を1つのツリーとして管理しようというReduxの目的に意味がないわけではなくて、特にサーバから取得してきたデータを保持することは結果的にキャッシュになるわけですが、一元管理ではなくuseContextなどでアドホックにやると性能面とメンテナンス面で問題が出てきます。
キャッシュの更新や同期、invalidateなどは、特にデータが大規模であったり複雑であったりすると難しいのです。ここをDevToolで可視化・試行錯誤しつつ一元管理できることは非常に有効だ、というのもReact Queryの価値でありニーズの一つでしょう。
みるみる減るぞ、状態が
そんなReact Queryを使っていくと、今まで状態管理ライブラリで扱っていたようなサーバ状態の管理や、ボイラープレートのパターン化されたuseEffectの処理がみるみるとReact Queryに巻きとられていくことになりたいへん心地よいものです。しかし、サーバ状態以外の
- UIの状態(ダイアログが開いているかどうかとか)
- サーバから取得したデータから計算で生成した中間的なデータ
- クライアントで計算した結果などの状態
など、いわゆるクライアント状態は残ります。まあこれらはuseContextなり、Recoilなりで従来的な状態管理をすればいいわけですが、いっそすべてをReact Queryで扱いたいものだという欲がわいてきます。その方法を示すのがこの記事の主題です。
状態管理ライブラリとしてのReact Query
ここでいったん、React QueryのメンテナであるTkDodo(@tkdodo)さんの記事React Query as a State Manager(状態管理としてのReact Query)を紹介しておきます。
この記事では、「React Queryが実際にはデータフェッチライブラリではない、非同期の状態管理である」とされています。実際fetchするのはReact Queryの第一引数に我々が渡すQueryFn関数がやることであって、React Queryが実際の通信処理を行うわけではなく、通信に限らず非同期処理はすべて対象になります。
React Queryの非同期処理では、スマートリフェッチ、stale-while-revalidate, SWRといった高度な戦略を使用することができます。
💡 コラム: SWRとは 💡 |
---|
SWR(stale-while-revalidate)というのは、もともとはHTTP通信におけるサーバサイドキャッシュ戦略を指定するための特定ヘッダの名称です。ReactではSWRの考えかたを使ったライブラリであるSWRがありますが、これは通信にstale-while-revalidateヘッダを使うわけではなく、あくまでそのキャッシュ戦略アルゴリズムを採用しているだけです。React QueryではクライアントサイドのキャッシュにSWR戦略を使用しています。SWRアルゴリズムの詳しい詳細は省きますが、直感的にはキャッシュが古くなって「賞味期限」になると捨てなければならないところ、「一週間ぐらいは賞味期限過ぎてもだいじょぶだろ」といって捨てないで期限を一定期間(useQueryのオプションで言うstaleTime)延長することに相当します。ただしその裏で新しい品物を注文しておきます。React Queryの文脈では、裏で注文したものが届いた時点でuseQuery hooksが発火してrenderの再更新が走るということがポイントです。ほどほどに新しい情報をポーリングによるプリフェッチなし、ローディングスピナーでぐるぐる表示の待ちなしで利用者に提供することができます。 |
そして記事の最後のところですが、
React Query is great at managing async state globally in your app, if you let it. Only turn off the refetch flags if you know that make sense for your use-case, and resist the urge to sync server data to a different state manager. Usually, customizing staleTime is all you need to get a great ux while also being in control of how often background updates happen.
再フェッチフラグをオフにすれば、残った数少ないクライアント状態のために、ReduxやRecoilの別の状態管理を使わなくて済むようになるよと書いてありますね。
ということで、お墨付を頂いたということでやってみます。
React Queryでクライアント状態管理
そもそもReact Queryでのクエリの結果は、クエリキーをキーとするキャッシュ領域に保存されます。そこでのキャッシュエントリ管理機構をクライアント状態とみなして共有すればよいわけですが、最低限やらなければならないことは以下です。
- クエリにおいてフェッチ関数(queryFn)の呼び出しを抑制する。
- useQuery呼び出し時に「enabled: false」オプションを指定することでそうなります。
- QueryClientのsetQueryDataメソッドでデータを設定する。
- mutationでは変更しないのでキャッシュを直接変更する。
- 必要なら初期値を設定する。
- useQuery呼び出し時に「initialData」オプションで設定します。
なお、データの取得はQueryClient.getQueryDataでもできるのですが、結局useQueryで取得しないと画面更新が引き起されないし、さらに特定条件でガーベジコレクションされてしまうので、値はuseQueryで取得するのが良いでしょう。
クライアント状態を読みとる
クライアント状態から値を読み出すのはこんな感じです。
const {data} = useQuery(['querykey'], { enabled: false }); // enabled: falseでフェッチを抑制
or
const {data} = useQuery(['querykey'], { enabled: false, initialData: <初期値> }); // 初期値を指定
このようにenabled:falseを指定するとフェッチが抑制されます。フェッチするqueryFn関数も渡していないので、何かの理由でフェッチがキックされると実行時エラーになります。
キーは通常のuseQueryのクエリキーで構造をもつこともできます。このキーに対するuseQuery呼び出しを行うことで、データの共有がおこなわれます。
注意すべきことは、動的なキーを指定はできないことです。たとえば:
const {data} = useQuery(['querykey', resultOfOtherQueryOrState], { enabled: false });
のように動的なクエリーキーを与えてキーを変化させると、変化する前のクエリキーに対するウォッチがなくなるので、変数がガーベジコレクトされて(?)消えてしまいます。
この事の帰結として、こうして定義するクライアント状態は、サーバ状態を含むクエリ依存グラフの先端の葉にはなれても、中間ノードにはなれないということになります。
クライアント状態を書き込む
クライアント状態として値を設定するのはこんな感じです。
const queryClient = useQueryClient();
queryClient.setQueryData(['querykey'], <設定する値>);
書き込む際には、useQueryClientフックをつかってQueryClientを取得し、キャッシュに直接書き込む同期的なAPIであるsetQueryDataを呼び出します。これでこの状態をウォッチしているuseQueryが発火します。
ラッパーフック「useQState」を定義する
これだけなのですが、上記の処理をシンプルに行うラッパーフック「useQState」を作ってみます。工夫としてクエリキーを指定した上で、useStateと同じように使えるようなインターフェースにしてみました。つまり:
- 呼び出すと、「クライアント状態値」とそのsetter関数の2要素の配列を返す
- setter関数には、設定する値を指定するバージョンに加え、「前の値から次の値を計算する関数」を引数で与えられるバージョンの両方を受け取ることができるようにする(useStateと同じノリで使える)。
以下のようになります。
export function useQState<T>(key: QueryKey, initial?: T): [T, Dispatch<SetStateAction<T>>] {
const stateValue = useQuery<T>(key, {
enabled: false,
...((initial !== undefined) ? { initialData: initial } : {})
}).data as T;
const queryClient = useQueryClient();
const stateSetter = (arg: ((arg: T) => void) | T): void => {
let newValue;
if (typeof (arg) === 'function') {
const prevValue = queryClient.getQueryData<T>(key);
newValue = (arg as any)(prevValue)
}
else {
newValue = arg;
}
queryClient.setQueryData<T>(key, newValue);
}
return [stateValue, stateSetter];
}
useQStateを使った記述を以下に示します。
useQStateを使ったクライアント状態の読み取り
値を取得してみます。
const [data, setData] = useQState(['querykey']); // フェッチは抑制される
or
const [data, setData] = useQState(['querykey'], <初期値>); // 初期値を指定
or
const [data] = useQState(['querykey'], <初期値>); // 参照だけならsetter関数は不要
useQStateを使ったクライアント状態の書き込み
値を設定してみます。
setData(<設定する値>);
or
setData((prevValue) => (<設定する値>)); // 関数で前の値を参照
useQStateサンプル
使用例としてよくあるカウンターボタンを作ってみると以下のようになります。
const [counter, setCounter] = useQState(['counter'], 100);
:
<div>count = {counter}</div>
<button onClick={
() => setCounter((prevValue) => prevValue + 1)
}>Increment counter</button>
<button onClick={
() => setCounter(5)
}>SET counter to 5</button>
表示は以下のようになります。
めっちゃシンプルに書けるようになりました❗💐 🎉 🎉 🎉 🎉 🎉
しかもuseState感覚で使えてわかりやすい❗✌✌(個人の感想です)。
なお、このインターフェースからは今のところuseQueryで指定できるような細かいオプションは指定できません。そうしたいときはuseQueryを使ってください。
性能面について言うと、キャッシュキーから引くのでuseQueryでのキャッシュ使用と同程度のオーバーヘッドはあると思われます。
アプリを作って試してみる!
実際のアプリにおいて、React Queryでクライアント状態管理をサーバ状態と組合せて使うとどうなるかを調べるために、サンプル開発してみます。ここでは以下のような画面を持ったチャットアプリを作ってみることにします。ソースコードはこちら。
Pleasanterを使ったサーバサイドの実装
その前に、サーバサイド実装としてPleasanterを使って簡単に作っておきます。
Plesanterとは何かというと、インプリムという会社が作っているオープンソースのいわゆる「ローコード・ノーコードプラットフォーム」っつうやつで、さまざまなアプリを作れるのですが、ここではPostgresをWebインターフェースで操作できるもの、特にWeb APIを自動生成してくれるものとして使います。
docker composeを用意してくださっている方がいらっしゃるので、以下のような手順でdockerで実行しローカルでデータベースを準備しておきます。
$ git clone https://github.com/yamada28go/pleasanter-docker-PostgreSQL server
$ cd server
$ docker-compose build
$ docker-compose up -d
$ docker-compose exec pleasanter-web cmdnetcore/codedefiner.sh
これでPleasanterが立ち上がるので、http://localhost にアクセスすれば、以下の初期ID・パスワードでログインできます。
user: Administrator
pass: pleasanter
続いて、サイト生成(いわゆるテーブル生成)のために、こちらのサイトパッケージ3つをインポートした上で、初期ユーザと初期ルームをそれぞれusers、roomsサイトに生成しておきます。
この裏ではPostgresが起動しているわけです。あまりにも簡単でしびれます。
その上でPleasanterの管理画面から得る以下の情報をcreate-react-appのトップディレクトリの.envファイルに転記すれば準備完了です。
REACT_APP_APIKEY=<Pleasanter API key>
REACT_APP_TABLE_ID_USERS=<users table ID(site id)>
REACT_APP_TABLE_ID_ROOMS=<rooms table ID(site id)>
REACT_APP_TABLE_ID_MESSAGES=<messages table ID(site id)>
クライアント状態の配線
さて、Reactアプリの説明に戻ります。コンポーネント構成は以下のとおり。
React Queryを使わないとき、データのフェッチはこの場合、上記コンポーネントの階層の一番外側つまりChatPanel.tsxなどでフェッチすると思いますが、Rect Queryを使う場合、それぞれのコンポーネントでフェッチしていけば良いことです。たとえば、MessagesArea.tsxでは以下のようにフックを呼んでいきます。
export default function MessageArea() {
const classes = useStyles();
const [loginUser] = useQState<number>(['loginUser'], 0) //(1)
const [selectedRoom] = useQState<number>(['selectedRoom'], 0); //(2)
const [selectedUser] = useQState<number>(['selectedUser'], 0); //(3)
const { data: allUsers, error } = useQuery<User[]>(['users'], tables.users.fetchTable); //(4)
const { data: selectedMessages } = useQuery<Message[]>( //(5)
['messagesOnRoom', selectedRoom],
tables.messages.fetchTable,
{
select: (mes) => mes.filter((mes) => mes.roomId === selectedRoom)
});
ここで(1)loginUser,(2)selectedRoom,(3)selectedUserは、前述のuseQStateを使ってアクセスしているクライアント状態変数です。ここでは参照だけしているので、setter関数は獲得していません。
後半の(4)allUsers、(5)selectedMessagesは、APIサーバにフェッチしに行くReact Queryの通常クエリです。注目すべきは、
const { data: selectedMessages } = useQuery<Message[]>(
['messagesOnRoom', selectedRoom],
tables.messages.fetchTable,
でのクエリキー中でのslectedRoomの使用で、クライアント状態であるselectedRoomを、クエリキーの一部に使うことができ、このコンポーネントないし他のコンポーネントでselectedRoomクライアント状態が変更されることで画面再更新が走るということです。まああたりまえですけれども。React QueryはDevToolが充実していてすばらしいのですが、これらのクライアント状態の値は以下のようにReact Queryの管理変数としてサーバ状態と合せて値を見たりすることができます。
次に、useQStateの返すsetterを使う例を説明します。
LoginForm.tsxコンポーネントでは、
const [loginUser, setLoginUser] = useQState<number>(['loginUser']);
と宣言してsetterであるsetLoginUserを取得し、
const handleSelect = () => {
setLoginUser(selectedUser);
};
:
</DialogContent>
<DialogActions>
<Button onClick={handleSelect}>OK</Button>
</DialogActions>
こんな風にsetLoginUserを呼びだすと、それに依存している他のコンポーネント、たとえばChatPanel.tsxの以下のような箇所のuseQStateが発火し、ログインした状態の画面を表示します。
const [loginUser] = useQState<number>(['loginUser'], 0)
return (
<>
<Grid className={classes.root}>
<nav className={classes.navigation}>
<div className={classes.toolbar} />
{loginUser !== 0 &&
<>
<RoomList />
<UserList />
</>
}
まとめ
状態管理ライブラリとして優れたものは他にもあるのでしょうが、少なくともReact Queryをメインで使っているなら、あえてコンセプトの全く異なる他のライブラリを併用し、相互連携を行うよりもReact Query自体の状態管理でまかなってしまうことで、全体としてのシンプルさ、保守性などを高め、かつ学習コストを抑えることができる、と感じました。たとえば、React Queryではselect:オプションをつかって、状態更新に伴なう画面更新を絞り込んでの最適化が可能であったり、React Queryでの最適化テクニックなどがありますが、クライアント状態管理として利用する場合でも多くを適用することができ、他の状態ライブラリの技法を別途習得したり、連携を考慮する必要がありません。
また、React QueryはDevToolや機能が充実していることもあり、本記事で提案したuseQStateフックのような工夫を加えることもでき、わかりやすさとしても他のライブラリよりも単純に優れていると感じられるユースケースもあるかもしれません。
あるいは、Redux Sagaなどでフェッチのカスケードやエラー処理、非同期処理のキャンセルなどの強力な能力を駆使(参考:「Redux-Sagaでテトリス風ゲームを実装して学んだこと」)していた場合、依存クエリやクエリのキャンセルなどを扱えるReact Queryは魅力的な移行先となるはずです。その際、Redux+Saga代替の、状態+非同期管理を将来性含めて考えるよりも、React Queryで完結させた方が幸せになれるかもしれません。
もっとも自分はReact Queryをそれほど使い込んでいるわけではないので、不備や他の観点、甘いところなどありましたら、コメントなどでご指摘ご享受いただけますと幸いです。
読者、React開発者のみなさんにおかれましては、来年もまた良いReactライフを!
明日は@j-yamaさんがElasticsearchの記事を書いてくれます乞うご期待。引き続き、NTTテクノクロス Advent Calendar 2021をお楽しみください。