Recoilにロジックを載せる運用戦略
皆さんこんにちは。株式会社バベルでエンジニアをしている uhyo です。バベルが提供しているaileadというプロダクトではNext.jsおよびReactを使用しています。以前から、自分はaileadのフロントエンドにおいてRecoilの利用を推進する活動をしてきました。実は、筆者が以前に公開した次の記事もその流れを汲んだものです。
Recoilはステート管理ライブラリとして知られていますが、筆者はRecoilのデータフローグラフを構築しその上にアプリケーションロジックを載せられるという点に可能性を感じています。実際、aileadではそのような方向性の設計に取り組んでいます。
そこで、この記事では筆者がaileadにおいて実践しているRecoilの運用を紹介します。
この記事はReact Advent Calendar 2022の8日目の記事です。
ステート更新に反応するならまずselectorを検討する
具体的にaileadに存在する、そして世間でもよくありそうなユースケースとして、検索画面において「検索条件が更新されたら新しい検索結果が表示される」というロジックを取り上げます。内部的には、検索条件を表すステートが更新されたら再度検索結果をリクエストしなければいけません。aileadでは、この場合にselectorを使っています。
Selectorは、依存先のステート(atomまたは別のselector)の値が更新された場合に再計算されるステートです。React Hooksで言うところのuseMemo
や、Vueで言うところのcomputed
におおよそ相当するものです。そして、selectorの特徴は、その計算を非同期処理で記述することができる点です。これにより、「検索結果をリクエストして取得する」のような処理もselectorとして自然に記述することができます。ReactにはSuspenseが備わっていますので、非同期処理の結果を使用するところも、同期処理と同じ感覚で記述することができます。RecoilのAPIにもこのあたりが織り込まれており、useRecoilValue
といったAPIを利用すれば、selectorの計算が同期的か非同期的かを気にせずにステートの値を取り出すことができます[1]。
従来の方法に比べた利点
「同じ目的が達成できるならばuseEffect
よりもuseMemo
のほうが良い」というのは多くの方が同意してくださるところでしょう。Recoilの非同期selectorを用いると、同じ考え方を非同期処理にも適用できます。
従来の典型的な方法は、非同期処理の場合は「検索条件が変わったらuseEffect
が発火して再検索のリクエストが発火する」というやり方です。
React Advent Calendar 2022の1日目を飾った以下の記事でも指摘されている通り、useEffect
というのはただ依存配列を監視するだけでなく、レンダリングを契機に発火されるものです。
そのため、useEffect
を使う場合は必然的に、「新しい検索条件がレンダリングに反映されたが、検索結果は古いまま」という整合していない状態が一瞬レンダリングされてしまうことになります。useEffect
の中でステートを即更新してローディング中にすれば画面に中途半端な状態が出ることは防げますが、これはアンチパターンとして知られており望ましくありません。ちなみに、アンチパターンなのは無駄な処理を増やしてパフォーマンスを低下させるからです。とくに、検索条件を更新するということはユーザーの操作に反応して画面を更新することになるので、ユーザー体験のためには最速でレンダリングを終わらせることが必要です。また、中途半端な状態でレンダリングを成功させてしまうと別のuseEffect
が中途半端な状態で発火したりして泥沼にはまります。このように、アプリケーションのパフォーマンスとメンテナンス性双方の面から、中途半端な状態でレンダリングを走らせるのはよくありません。
ここでRecoilの出番です。RecoilではReactの外のレイヤーで計算を行なってしまって、完成した計算結果をReactに見せます。そのため、例えば「検索条件が変更したら検索結果を再取得する(=検索結果がローディング中になる)」というロジックがRecoilで書かれていれば、React側には「検索条件の変更」と「ローディング状態への変化」が同時に起こったように見えます。これによって、前述の問題が解決されます。これがRecoilの利点です。
そもそもReactはレンダリングの一貫性の保証を提供しています。それは、(useTransition
で明示的にオプトアウトしない限り)画面全体のレンダリングがある特定の瞬間のステートから行われるということです。ステートの更新の際に、画面(コンポーネントツリー)の一部だけ新しいステートでレンダリングされ、残りは古いステートが反映されたままというようなことは起こりません(起こったらReactのバグです)。
Recoilの非同期セレクタは、この保証を非同期処理も含めた場合に拡張してくれるものだと考えられます。Recoilデータフローグラフの中身は全体が同時に更新されるように見えます(非同期ステートの場合は一瞬で結果が出るわけではありませんが、それでも「ローディング中という状態への遷移」は同時に行われます)。この点で、Recoilの考え方はReact本体の考え方とマッチしています。
ただ、最近アイデアが発表されたuse
APIは、非同期なステートを全部PromiseとuseMemo
にしてしまうことでRecoilの機能を代替できる可能性を秘めています。これが実用化されるとRecoilを採用する意味が薄れてくる可能性はありますね。
GraphQLリクエストもRecoilに載せた
aileadでは、フロントエンドとバックエンドの通信にGraphQLを用いています。Recoilでは非同期処理もselectorにできるので、GraphQLリクエストもselectorとして表すことにしました。従来のReact向けGraphQLクライアントはフックとして提供されているものが多いですが、フックを用いると実質的にuseEffect
を経由しているのと同じことになってしまうので、フックを用いるのはベストな方法ではないと考えました。
具体的には、GraphQL Code Generatorのtyped-document-nodeプラグインを用いてGraphQLドキュメントから型のついたオブジェクトをコード生成します。そして、それを渡すとselectorが返ってくるutilを作りました。使い心地はこんな感じです(クライアントとしてurqlを使っているので名前がcreateUrqlSelector
となっています[2])。
// graphql-codegenで生成されたTypedDocumentNode
import { MyQueryNode } from './my-query.generated.ts'
// RecoilStateReadonly<MyQueryResult> 型
const myQueryState = createUrqlSelector({
key: 'my-query',
query: MyQueryNode
});
// このようにすればリクエストを発火しMyQueryの結果を得ることができる
const useMyQuery = () => {
return useRecoilValue(myQueryState)
}
Queryに入力(variables)が必要なケースがありますが、その場合にも対応しています。具体的には、selectorの値を計算する場合と同じようにvariablesの値を計算することができます。
import { MyQueryNode } from './my-query.generated.ts'
const keywordState = atom<string>({
key: 'keyword',
default: ''
});
const myQueryState = createUrqlSelector({
key: 'my-query',
query: MyQueryNode,
getVariables({ get }) {
return {
keyword: get(keywordState),
}
}
});
getVariables
の引数はselectorのget
と同様になっています。これを用いてcreateUrqlSelector
によって作られるselectorがどのステートに依存するのか宣言できます。もちろん、依存先のステートが変化したらクエリが再実行されます。APIから察せられるように、createUrqlSelector
の内部でvariables用のselectorを用意して使っています。このように、ローカル変数ならぬローカルselectorを作れるのもRecoilの面白いところですね。カスタムフックの中だけに存在するuseStateみたいな感じです。
この仕組みにより、GraphQL QueryをRecoilデータフローグラフ(atomとselectorからなるグラフ)の中に組み込むことができました。もちろん、createUrqlSelector
によって作られるのもselectorなので、それをさらに別のselectorで加工することも可能です。
ちなみに、Recoilとかなり類似したアーキテクチャを持つjotaiというステート管理ライブラリではurqlとのインテグレーションが公式で用意されています。この記事で紹介している内容は大体jotaiでもできると思いますので、好みに合わせて選びましょう。
ページネーションの例
検索にさらにページネーションを加える場合を考えてみましょう。ステート設計の鉄則は他のステートに依存せずに変化させられるものをatomにすることなので、ページもatomにします。
import { MyQueryNode } from './my-query.generated.ts'
const keywordState = atom<string>({
key: 'keyword',
default: ''
});
const pageState = atom<string>({
key: 'page',
default: 0
})
const myQueryState = createUrqlSelector({
key: 'my-query',
query: MyQueryNode,
getVariables({ get }) {
return {
keyword: get(keywordState),
page: get(pageState),
}
}
});
こうすれば、ページを前後に移動する際はpageState
を書き換えればよいことが明らかです。こういうのを実装する際は「ページが変化したら検索リクエストを再発行しなきゃ」などと考えてしまいますが、検索結果がselectorとして表現されていることにより、依存先のatomを書き換えたのだからselectorの結果も自然とアップデートされるだろうという考え方ができます。Selectorという概念が責務の分離を手助けしてくれていますね。
ちなみに、検索条件が変わったら最初のページに戻したい場合はどうしたらよいでしょうか。「検索結果が変わったらpageState
がリセットされる」というように宣言的に書きたいですよね。ということで、aileadではatomResetOnDepsUpdate
というutilを用意しています。
const pageState = atomResetOnDepsUpdate({
key: 'page',
default: 0,
deps: keywordState,
});
このようにすると、pageState
は相変わらずatomですが、deps
に渡されたstateの値が変わるとpageState
の値は初期値に戻ります。
Recoilベースアーキテクチャの考え方
以上の例でなんとなく雰囲気が掴めたかと思いますが、現在筆者が好んでいるやり方はこのようにRecoilデータフロー上になるべくロジックを書くやり方です。そもそもRecoil本体にもいろいろなutilが提供されています。例えばwaitForAll
(Recoil版のPromise.all
みたいなやつです)などは、selectorを渡すと別のselectorを返すAPIとして実行されています。Recoilにもともとutilを使って多様なselectorを作っていく文化があるので、それに乗じて上述のようなutilを作っています。waitForAll
も内部的にはselectorFamilyで作られており、RecoilのAPI自体もこのようにutilを作るのに適したAPIになっています。
フロントエンドにおいても、コアロジックはデータをどう取り回すかというところにあり、そこはなるべく宣言的に書きたいものです。このような宣言的なロジック記述を行うにあたってRecoilという道具は優れているように思います。ちょうど、React本体がUIを宣言的に書かせてくれるのと対称的です。
ちなみに、「元となるデータが変わったら依存先のデータも変わる」というのは、いわゆるリアクティブプログラミングと呼ばれる技法に近いものを感じますね。非同期処理のパイプラインを作れるところなどは特にそうです。
フロントエンドでリアクティブプログラミングといえばRxJSなどが思い浮かびますが、それらとは異なり、Recoil自体にはストリームとかイベントベースといった概念は特に感じられません。(内部的にはサブスクリプション等があるものの)Recoilの外から観測されるのはある時点のスナップショットであり、ある時点のスナップショットをどのように計算するのか宣言的に定義するのがRecoilの役目です。Reactと接続することによって、Recoilの役目がサブスクリプション管理ではなくデータのスナップショットの提供に明確化されているのが面白いところです。
key管理は? ディレクトリ構成は?
以上でアーキテクチャの話が終わったので、次はもう少し地に足がついた話を紹介します。
ディレクトリ構成
まずはディレクトリ構成です。ステート管理に限らず、Reactプロジェクトのディレクトリ構成については色々な人が色々なことを言っています。こういった問題には確実なひとつの正解というものはありません。ですから、ここで紹介するのもひとつの考え方程度に捉えてください。
aileadのプロジェクトでは、Recoilはロジックを書く場所であり、atomやselectorは依存関係を通じて関係し合うということを重視し、atom・selectorをまとめたdataflow
ディレクトリを作りました。グローバルな(アプリ全体から参照される)データを含むロジックについては、このようにcomponents
やpages
といったディレクトリと並べる構成にしました。
src
|
+-- components
+-- dataflow
|
+-- searchQuery.ts
+-- ...
+-- pages
Recoilがリリースされた当時に筆者が書いた記事ではatomやselectorをファイルからエクスポートすべきではなくカスタムフックをエクスポートすると良いのではないかと述べましたが、Recoilを単なるグローバルステート管理ではなくロジックの置き場所として運用することにしたので、これらをエクスポートすることもアリという考え方に変わっています。Recoilでデータフローを構築して最大限の計算を行い、末端でReactコンポーネントに接続するイメージです。
最終的にReactコンポーネントと接続する窓口については、今のところとりあえずdataflow
内に用意したカスタムフックとしています。ただ、Recoilの概念を直接エクスポートするようにしたので、これにはあまり強い理由を感じていません。Reactの世界からからRecoilの世界に依存することは普通にできますが、逆は自然にはできないので、特段追加の制限を必要としていないというところです。
ページ単位のRecoilステートについて
上のsrc/dataflow
ディレクトリは、アプリ単位のグローバルなステートを管理するatomやselectorのためのものです。我々は、それだけでなくページ単位のRecoilステートも使用し始めています。例えば、上の例のsearchQuery.ts
は検索条件ですが、ページを移動しても入力した検索条件を保持したいという需要からこれはアプリ単位のステートとしています。一方で、実際の検索結果については、検索ページは以下のステートとして問題ありません。
ページ単位のRecoilステートはおおよそ次のようなディレクトリ構成になっており、そのページ用に切ったディレクトリの中にdataflow
ディレクトリを設けています。Recoilを用いたロジックはやはりこの中に書きます。
src
|
+-- components
|
+-- SearchPage
|
+-- dataflow
| |
| +--- searchResult.ts
|
+-- index.tsx
...
各ページ用のRecoilステートは、より上位の(アプリ全体用の)Recoilステートに依存しても構いません。この例では、検索条件のステートはアプリ全体にあり、createUrqlSelector
で作成したリクエスト用のステートはSearchPage/dataflow
内にあるというイメージです。
逆に、上位のステートから下位のステートに依存することは意味的に不自然なので行いません。今のところこれに関して機械的なチェックは行っていませんが、必要であればESLintでチェックすることができるでしょう。
key管理
Recoilにおいては。各atom・selectorにユニークなkey文字列を与える必要があります。これをベースに、atomFamilyやselectorFamilyなどによって作られたatom・selectorにもkeyが与えられます。先述のjotaiのようにkeyを必要としないライブラリもありますが、keyがあるとステート一覧をシリアライズしやすいといった利点もありますので、筆者としてはkeyはそこまで嫌いではありません。しかし、プロジェクトでユニーク性が担保された文字列を用意しないといけないのは結構面倒です。
今のところ、aileadではふんわりした命名規則を運用しています。例えば、src/dataflow/searchQuery.ts
が持つ検索結果のステートのkeyはdataflow/searchQuery
というように、dataflow/ファイル名
という命名を原則としています。1つのファイルの中に、メインのステートの他に補助的なステートを含む場合は、dataflow/ファイル名/ステート名
という命名です。各ページ用のステートに関してはSearchPage/dataflow/searchResult
のような命名となります。これもまた、必要であればESLintでルール化・自動化できるでしょう。
Recoilのテスト
Recoilにデータ処理のロジックを載せるとなると、気になるのがテストをどうするのかというところです。特に、変換という形でロジックが載ったselectorのテストが重要です。
Recoilに関する基本的なテスト方針は、selector一つずつに対するユニットテストを書くというものです。せっかくRecoil上のロジックがselectorという単位で疎結合になっているので、selectorという単位はユニットテストの対象として適切に思えます。
そのためには、テスト対象のselectorが依存する他のatom・selectorを全てモック[3]して、対象のselectorの出力をテストします。Selectorの出力をテストする方法は、公式ドキュメントで紹介されているReact Testing LibraryのrenderHook
でテストする方法を基本としています。実際のユースケースに合致しているので何となく信頼感があります。snapshot_UNSTABLE
を使えばReactを使わなくてもテストできますが、フックを噛ませることでSuspense周りの処理をReactが持ってくれるので、Testing Libraryから提供されているwaitFor
などのハイレベルなAPIを使ってテストが書けるのが良いと考えています。
一方で、そうなるとselectorの依存先の値をモックするのが大変です。Recoil本体から提供されている方法はそこまで使い勝手がよくありません。また、Recoil標準の方法ではselectorの値を好きにモックすることができません。
ということで、株式会社バベルではRecoilのatomやselectorをモックするときに便利なutilとしてrecoil-mock
を開発し、公開しました。
これを用いて、Recoilのテストを次のように行うことができます(リポジトリから引用)。まずcreateRecoilMockWrapper
でcontext
を取得し、それを通じて任意のatom・selectorの値を差し替えることができます。また、得られたwrapper
はReact Testing Libraryのrender
やrenderHook
に渡せば機能します。
test('mock Recoil atom', async () => {
const { context, wrapper } = createRecoilMockWrapper();
context.set(fooAtom, 'bar');
const { findByText } = render(<MyApp />, { wrapper });
// The mocked value is applied
await findByText('foo is bar');
// If you update mocked value after rendering, you should wrap it in an `act` call.
act(() => {
context.set(fooAtom, 'pika!');
});
// Your app should have reflected to the update here.
await findByText('foo is pika!');
});
内部実装としては、jestの設定でrecoil
の実態をrecoil-mock
に差し替えることで、全てのatomやselectorをモック用のレイヤーでラップしています。これにより、selectorであっても本来の値を無視して好きな値を返させることができます。
上の例はatomを使用するコンポーネントのテストコードになっていますが、同じ要領でselectorのユニットテストも可能です。
まとめ
この記事では、aileadにおけるRecoilの利用について説明しました。実は、まだこの記事で説明した内容が完全に運用に載っているわけではなく、まだ移行が始まったばかりです。多くのコードではまだuseQuery
のようなフックからGraphQLリクエストを送っていますし、ページごとのRecoilステートの活用もまだあまり進んでいません。
ただ、従来useEffect
などで書かれていたロジックがRecoilになることで見通しが良くなる効果を実感しています。従来の実装では、ややこしいところだとデータの流れがどうなっているのか全然わからない状況に陥っていましたが、Recoilを使った実装では読めば分かるようになっています。
Recoilの概念について学ぶのはReactのフックを一通り理解する程度の学習コストはかかる気がしますが、それに見合う効果があると考えています。皆さんもぜひRecoilを活用してみてください。
-
ただし、トランジションの恩恵を受けたい場合は
useRecoilValue_TRANSITION_SUPPORT_UNSTABLE
のようなAPIを使う必要があり、まだunstableなことを除いても、APIがこなれていないのが難点です。ただ、これは時間が解決してくれるだろうと考えています。 ↩︎ -
この関数は生成されるselectorのkeyを指定するAPIになっています。SelectorFamilyを使えばkeyが不要になりますが、今回はDocumentNodeに対して一致判定が走るのが重いためselectorFamilyを使わないAPIにしました。 ↩︎
-
より正確には「スタブ化」と呼ぶのが正しそうですが、筆者はもう呼び分けなくても別によくない? という考え方を採用しています。 ↩︎
顧客満足度No.1*の商談解析クラウド ailead(エーアイリード)を提供しています。 AIが商談データを自動で収集・解析・可視化することで、営業現場の業務効率化と「売れる」営業人材の育成を実現します。 ailead.app/
Discussion
はじめまして。興味深く拝読させていただきました。
こちらは TanStack Query などのSWRライブラリを指しているのでしょうか。
その場合、通信キャッシュのような仕組みは自前で実装することを想定されているのでしょうか。
ご感想ありがとうございます🙂
まだRecoilが導入されていないところはApollo Clientの
useQuery
が使われています。キャッシュに関しては、するどいご指摘かと思います。実はRecoil内部で発行されるGraphQLリクエストには特にキャッシュの仕組みを設けてはいないです。
ただ、selectorは依存先が変わるまで再計算されないので、値を読むたびに同じリクエストが何回も飛ぶようなことはありません。
我々は今のところ、selectorのデフォルトの挙動を超えたキャッシュを特に必要としていないので、そこは省いています。
もし将来的により賢いキャッシュが必要になった場合、自前で実装することを想定しています。
さっそくのお返事ありがとうございます!
たしかにデータベースの更新頻度によってはローカルのキー依存にしてしまい、キャッシュは考慮せず Recoil に一本化してしまって良さそうですね。
たいへん参考になりました。ありがとうございます。
多分Recoilについては、ぼんやりと同じようなことを感じていたのですが、uhyoさんが次々と言葉にしていってくれるので興奮して読んでいます。
atomResetOnDepsUpdate
のような物は確かに欲しいのですが、実装方針どのようになっているか、いつか記事にされることってありますか・・・?