uhyo/blog

Facebook製の新しいステート管理ライブラリ「Recoil」を最速で理解する

2020年5月15日 公開

昨日、Facebook製のReact用ステート管理ライブラリRecoilが発表されました。Facebook製といってもReact公式のステート管理ライブラリとかそういう位置付けではないようですが、それでも大きな注目を集めているのは間違いありません。

そこで、筆者がRecoilに対して思ったことや、筆者の視点から見たRecoilの特徴を記事にまとめました。

なお、この記事の執筆時点では副作用の扱いなどの点はいまいち情報が揃っていません。この記事では速報性を重視し、コアのステート管理部分に絞って考えています。また、まだexperimentalなライブラリなので、今後この記事の内容からRecoilのAPIが変化したとしても悪しからずご了承ください。

この記事を書くときに筆者が色々試していたCodeSandboxはこちらです。

概要

誤解を恐れずに一言でまとめれば、RecoilはReduxからreducerを消してフックに最適化したステート管理ライブラリです1

そう、Recoilの主要な比較対象はReduxです。なぜなら、パフォーマンスという観点からはRecoilのゴールはReduxと共通しているからです。

グローバルなステート管理(ステートが複数コンポーネントで共有される)においては、ステート更新時に無駄な再レンダリングを発生させないことが肝要です。RecoilもReduxも似たようなアプローチでこの問題に取り組みます。すなわち、Reactが提供するコンテキストに依存しない独自のsubscriptionシステムによって再レンダリングを管理するのです。

ReduxとRecoilが異なる点

一方で、ReduxとRecoilには大きく異なる点もあります。それは、Reduxではステートの宣言が中央集権的であるのに対して、Recoilはステートの宣言が局所的です。

Reduxにおける典型的なステート管理のパターンは、ロジックごとに定義されたreducerたちをcombineReducersでまとめた巨大なreducerを作り、ステートを使う側はuseSelectorを用い巨大なステートから自分が必要な部分を取り出すというものです。Reduxにおいては個々のステートを宣言する最終目的は「巨大なステートの部品となること」であり、この点が特徴的です。

一方、Recoilでは、個々のステートはそのステートを使いたいコンポーネントたちの間で直接共有されます。一度全部入りのステートを経由しないという点がReduxとの違いです。実際のところ、Recoilも裏ではステートを集約して扱っているかもしれませんが、それは暗黙的に行われ、RecoilのAPIに表面化しません2

このことからの重要な帰結として、ステートがcode splittingの対象になることがRecoilの利点として挙げられています。つまり、全てのステートを中央に集約するステップが無いことによって、ステートを使うコンポーネントが読み込まれるまではそのステートも読み込まないということが実現しています。

ちなみに、reducerが無いということは、Recoilにはアクションという概念もありません。個人的にはアクションの無いReduxが欲しいと思っていたので、Recoilが自分の求めていたものではないかと思っています。

フックと相性が良いAPI

RecoilのAPIはReduxとは大幅に異なる見た目をしていますが、フックとの相性の良さを念頭に設計されているのが見て取れます。フックの良いところは何と言ってもカスタムフックによるある種のカプセル化が可能な点であり、現代ではコンポーネントのロジックがほとんどカスタムフックに書かれるようになりました。Recoilが提供する各種のフックは、カスタムフックに組み込みやすいように作られています。それどころか、カスタムフックに組み込んでこそ真価を発揮すると言っても過言ではありません。

Reactにおけるカスタムフックは、関数のスコープやモジュールレベルのスコープを活用した多様なカプセル化ができる点が優れています。RecoilのAPIはその全てに適合し、アプリケーションロジックの疎結合化を促進するのです。

この記事でもRecoilの基本的な使い方をこれから紹介していきますが、RecoilのAPIを見た方は「なんだか原始的だ」と思うかもしれません。それは間違った感覚ではありません。Recoilが提供する各種のフックは、カスタムフックのパーツとして使いやすいように設計されているのです。

これは「シンプル」という言葉が適していると思います。RecoilのAPIは複雑さを適度に隠蔽しつつ、挙動に疑問の余地がなくかつ単純です。さらに言えば、あとで詳しく説明しますが、RecoilのAPIはReact本体の思想を受け継ぎ宣言的な側面も持っています。

RecoilとReduxが解決する問題

RecoilとReduxが共通する点は、パフォーマンス上の課題を解決するものであるという点でした。Recoilの使い方の説明に入る前に、これについて解説します。

パフォーマンス上の課題とは何かというのは、言い換えれば「useReducer + useContextでうまくいかないとのはどういう時か」という問いでもあります。これはReduxにも共通する話ですから、Reduxの理解者ですでに知っているという方は次の「Recoilの基本的な使い方」まで飛ばしても構いません。

React単体での共通ステート管理とその限界

React本体にも、値を複数コンポーネントで共有する手段が用意されています。そう、コンテキストです。React 16.3で導入されたコンテキスト機能では、コンポーネントツリーの上流のコンポーネントがProviderに渡した値を下流のコンポーネントがuseContextで取得することができます。上流のProviderに渡された値が変わった場合は、useContextでその値を読んでいたコンポーネントが再レンダリングされ、値の変更に追随します。

コンテキストを用いることで、簡易的なグローバルステート管理が実現できます。上流のコンポーネントではuseStateuseReducerを用いて共通ステートを定義しそれをコンテキストに入れることで、下流のコンポーネントではuseContextを用いてステートを取得することができます。

小規模なアプリケーションではこの方法でも十分な場合がありますが、パフォーマンスが重視される場合は問題があります。

多くのステートをこの方法で管理する場合、ひとつの選択肢はReduxよろしく全てのステートを詰め込んだオブジェクトでステートを管理し、一つのコンテキストにそのオブジェクトを流すというものです。この場合、ReduxのuseSelectorはこんな感じで再現できます。

const useSelector = (selector) => {
  const allStates = useContext(StateContext);
  return selector(allStates);
};

このuseSelectorのパフォーマンス上の問題は、いかなるステートの更新も、useSelectorを使う全てのコンポーネントの再レンダリングを引き起こすということです。ステートの更新が発生したらそれに関係するコンポーネントのみ再レンダリングされてほしいところ、一つのコンテキストに全ての情報を入れてしまう場合はそれに依存する全てのコンポーネントが再レンダリングされてしまいます。一定以上の規模のアプリではこれは受け入れがたい問題です。

この問題を緩和する策としては、コンテキストを複数に分けるという方法が挙げられます。しかし、useSelectorを複数用意する必要があり煩雑ですし、より複雑なselectorを使いたい場合には無駄な再レンダリングが避けられない場合があります。

ここでの根本的な問題は、「ステートが更新されたら、そのステートの依存するコンポーネントが必然的に全て再レンダリングされる」点にあります。複雑な状況では、たとえステートが更新されても再レンダリングをしたくない場合がありますね。特に、ステートの値をそのままレンダリングに使うのではなく、ステートから別の値を計算して使う場合にこれが顕著です。

ステート管理ライブラリによる解決策

ReduxやRecoil、そしてそれに留まらない多くのステート管理ライブラリは「Reactの組み込みのコンテキストを使わない」ことによってこの問題を克服しています。そのために、Reactに頼らない独自のサブスクリプションの仕組みを持つことになります。これがステート管理ライブラリが提供する主要な価値であり、それをどのようなAPIで表現するかによってそれぞれのステート管理ライブラリの個性が出ているという状況です。そこに、Recoilは「フックとの相性」「シンプル」「宣言的」といった特徴を提げて参戦したことになります。

Reduxでは、「ステートから別の値を計算」の部分をselectorが担当します。そして、ステートが更新されても、そのステートを基にselectorが計算した値が変化していなければ、コンポーネントの再レンダリングは発生しません。ここにReduxの一番の本質があります。Reactのコンテキストが持つ「コンテキストの値が更新されたらコンポーネントが再レンダリングされる」という挙動に割り込むことはコンテキストを使用している限り不可能であり、Reduxは独自のサブスクリプションによってコンテキストをいわば再実装することでこれを達成しているのです。

もちろん、Recoilも同じ考え方を持っていると考えられます(ソースコードを読んだわけではないので確信があるわけではありませんが、多分合っていると思います)。Recoilにもselectorという概念があり、これはReduxのselectorと一対一に対応するものではありませんが、概ね似た目的を達成するために存在しています。そして、Recoilも、ステートが更新されてもselectorの結果が変わらなければコンポーネントの再レンダリングが発生しないのです。

では、いよいよRecoilのAPIがどのようなものかを見ていきましょう。

Recoilの基本的な使い方

Recoilの基本的な使い方は公式のドキュメントを見れば分かるのですが、この記事でもちゃんと説明します。

Atomを宣言する

Recoilでは、グローバルな(複数コンポーネントで共有される)ステートはAtomと呼ばれます。例えば、数値を値に持つ簡単なAtomは、Recoilが提供するatom関数を用いてこのように作れます。

const counterState = atom({
  key: "counterState",
  default: 0
});

Atomの宣言に必要なのは、デフォルト値とkeyのみです。keyというのはAtomを識別する文字列で、グローバルにユニークである必要があります。グローバルなユニーク性が求められるのは心配事が増えて個人的には気に入らないのですが、公式ドキュメントによれば高度なAPIにおいてAtomの一覧のようなものを扱うときに必要なようです。まあ、被ったらランタイムエラーとのことなので許容範囲でしょうか。

このように、Atomの宣言自体は単にステートを宣言するだけであり、そのAtomがどのように使われるかというロジックは含んでいません。これはちょうど、useStateがデフォルト値のみを受け取ってステートを宣言し、どう使われるのかに関与しないのと同じですね。

Atomを使う

コンポーネントからAtomを使うには、Recoilが提供するuseRecoilStateを使うのが最も基本的です。例えば、上記のcounterStateは次のように使えます。

const CounterButton = () => {
  const [count, setCount] = useRecoilState(counterState);
  return (
    <p>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
    </p>
  );
};

このように、useRecoilStateuseStateと同様のAPIを持ちます。ただし、デフォルト値を受け取る代わりにすでに定義済みのAtomを引数に受け取ります。返り値は[count, setCount]のような2要素配列であり、countがそのAtomの現在の値、setCountはAtomの値を更新する関数です。 この組はuseStateと全く同じであり、useStateによって宣言されたコンポーネントローカルなステートと同様の感覚で、counterStateというグローバルなステートの値を読み書きすることができます。もちろん、このcounterStateはグローバルなステートなので、CounterButtonコンポーネントを複数設置すればその値は全て連動することになるでしょう。

繰り返しになりますが、Atomの値をどう変化させるかは、そのAtomをuseRecoilStateを用いて使う側に委ねられています。このような設計は、reducerをベースとしたステート管理に比べると幾分原始的に感じられますね。後で触れますが、これはカスタムフックに組み込まれることを意図したデザインであると考えられます。

ここで注目に値するのは、atom({ ... })useRecoilState(atom)はどちらも素のReactでいうuseStateの類似物であると説明したことです。実際、この2つはセットでuseStateと同様の働きをします。というのも、通常のReactで「(コンポーネントに属する)ステートを宣言する」というのはuseStateを呼び出すことで行われましたが、RecoilではこれはatomによりAtomを宣言する段階とuseRecoilStateによりそのAtomを使用する段階に2つに分かれています。

これは、ステートをグローバルなものにしたことによる必然的な選択です。ReactのuseStateは「ステートを宣言する」役割と「そのステートを使用する」という2つの役割を持っていたと考えられ、Reactではステートはコンポーネントに属するので両者は不可分なものでした。一方、Recoilではステート(Atom)をグローバルなものにしたことにより、ステート(Atom)の宣言と、実際にそのAtomを使うコンポーネントは何かという宣言の2つが別々に行えるようになりました。これが、atomuseRecoilStateという2つのAPIが両方ともuseStateのアナロジーとして説明される理由です。素のuseStateとの違いは、1回のatomで作られたAtomに対して複数回(複数コンポーネントから)useRecoilStateできるという点ですね。

このAPIはとても宣言的なものであると評することができるでしょう。atom({ ... })が行なっているのはステートを作るということだけであり、従来のように「useStateによって手に入れられるステートの値」などではなく、「ステートそのもの」という概念がここに発生しています。我々は「ステートそのもの」を取り回す事ができるのです。useRecoilStateの効果は「(既知の)ステートを使う」という宣言であり、useStateの「ステートを作る」に比べてもなお宣言的度合いが増しています。

Atomを使うためのその他のフック

ドキュメントでは、useRecoilState以外にもuseRecoilValueuseSetRecoilStateというフックが紹介されています。これらはuseRecoilStateの用途が制限されたバージョンです。useRecoilStateが読み書き両対応だったのに対して、useRecoilValueは読み取り専用、useSetRecoilStateは書き込み専用です。

例えば、counterValueの値を表示したいだけの場合、次の2つの選択肢があります。

// useRecoilStateを使う場合
const [counter] = useRecoilState(counterValue);
// useRecoilValueを使う場合
const counter = useRecoilValue(counterValue);

ドキュメントでは、ステートに書き込まない場合はuseRecoilValueが推奨されていますから、それに従いましょう。 書き込まないのにuseRecoilStateを使うのは、書き込まない変数をconstではなくletで宣言するようなものです。

とはいえ、プログラムの読みやすさの問題であり、どちらを使っても動作は同じでしょう。

それよりも注目すべきはuseSetRecoilStateのほうです。これは、ステートの値を読まないけど更新はするという場合に使えるフックです。useRecoilStateと比較するとこうなります。

// useRecoilStateを使う場合
const [, setCounter] = useRecoilState(counterValue);
// useSetRecoilStateを使う場合
const setCounter = useSetRecoilState(counterValue);

これは一見使い所が無いように見えて、とても重大な意味があります。というのは、useRecoilStateとは異なり、useSetRecoilStateはAtomをsubscribeしません。つまり、useSetRecoilStateを用いてAtomへの書き込み権限のみを取得しているコンポーネントは、Atomの値が変わっても再レンダリングされないのです。Atomの値を読まないのだから、Atomの値が変わっても影響を受けないということですね。

これは、無用な再レンダリングを避けつつAtomの更新手段が得られるという貴重なAPIです。素のReactで言えば、useReducerのステートは見ないでdispatchだけをコンテキストで受け取るのと同じようなものです。この特徴により、useSetRecoilStateuseRecoilStateでは代替不可能なものとなっています。

また、Atomの値をリセットする関数を取得できるuseResetRecoilStateもあるようです。Atomに関しては「デフォルト値」というパラメータだけはAtomを使う側(useRecoilState等のフックを使う側)ではなくAtomの定義そのものに属しますから、この機能が別に用意されていると考えられます。具体的なユースケースはちょっと思い浮かびません。

useRecoilCallback

最後に、useRecoilCallbackというフックもあります。これは、Atomへのsubscribeは発生させたくないけどAtomの値を読みたいという贅沢な悩みを解決してくれるフックです。これまでとは毛色が違い、useRecoilCallbackuseCallbackの亜種です。

例えば、「クリックすると現在のcounterStateの値を表示するボタン」はuseRecoilCallbackを使うと次のように書けます。これはuseRecoilValueなどを使っても作ることができますが、useRecoilCallbackを使えばこのコンポーネントの再レンダリングを削減することができます。なぜなら、クリックした時にcounterStateの値を取得するようにすれば、counterStateが変わっても再レンダリングは不要だからです。この「クリックしたときにAtomの値を取得する」を実現するためのAPIがuseRecoilCallbackです。

const AlertButton = () => {
  const showAlert = useRecoilCallback(async ({ getPromise }) => {
    const counter = await getPromise(counterState);

    alert(counter);
  }, []);

  return (
    <p>
      <button onClick={showAlert}>Show counter value</button>
    </p>
  );
};

useRecoilCallbackuseCallbackと同様のインターフェースを持ちます。すなわち、第1引数にコールバック関数を受け取り、第2引数は依存リストです。普通のuseCallbackとの違いは、コールバック関数の第1引数のオブジェクトを通じてgetPromise関数を受け取れるということです(他にgetLoadable, set, reset関数も提供されます)。

このgetPromise関数を用いると、好きなAtomの値を取得することができます。ただし、結果はPromiseとなります。なぜ急にPromiseが出てきたのかといえば、Recoilは非同期なselector(後述)もサポートしているからです。useRecoilStateなどの場合は非同期の扱いはRecoil内部に隠蔽されていますが、useRecoilCallbackは、いわば副作用に属するようなもう少しローレベルなAPIであるため、このようにPromiseが露出することになります。Atomの値を変えたい場合はset(state, newValue)のようにします。

Selectorを使う

Recoilの基礎的な概念は、Atomの他にもう一つSelectorがあります。Selectorは、Atomの値から計算された別の値です。いわゆるcomputed property的なやつですね。Reduxのselectorも、ステートから値を計算するという点では似た概念です。例によって、Selectorの値から別のSelectorを計算する(Selectorを連鎖させる)こともできます。

Recoilでは、AtomとSelectorを合わせてStateと呼びます。これまでに出てきたuseRecoilStateなどのフックは、全てAtomではなくSelectorに対しても使うことができます。AtomとSelectorは値を提供するという点で共通しており、違いは自身が値を持っているか、あるいは他から計算しているかだけです。useRecoilStateはどちらも区別せずに扱うことができるのです。

Selectorは、Recoilが提供するselector関数を用いて作成します。まず宣言するという点でAtomととても類似していますね。

例として、counterStateの10分の1の整数を表すSelectorを定義してみましょう。

const counterState = atom({
  key: "counterState",
  default: 0
});

const roughCounterState = selector({
  key: "roughCounterState",
  get: ({get}) => Math.floor(get(counterState) / 10)
});

このように、Selectorの定義にはkeygetを含めます。getはそのSelectorの値を計算する関数です。get関数は引数からgetを受け取り(ややこしいですね)、そのgetを用いて他のState(AtomまたはSelector)の値を用いることができます。Selectorの値の計算中にgetされたStateは、そのSelectorからsubscribeされていると見なされます。

今回の場合、roughCounterStatecounterStateをsubscribeします。すなわち、counterStateの値が変わったとき、roughCounterStateの値も再計算されます。

このroughCounterStateはSelectorなのでAtomと同様に使うことができ、例えばこんなコンポーネントを書けるでしょう。

const RoughButton = () => {
  const roughValue = useRecoilValue(roughCounterState);
  return (
    <p>
      <button>{roughValue}</button>
    </p>
  );
};

ポイントは、roughCounterStateの値が変わらなければRoughButtonは再レンダリングされないということです。例えば、counterStateの値が0→1→2→…→9と変わる間、roughCounterStateの値は常に0です。よって、roughCounterStateの値は変わっていないと見なされ、RoughButtonは再レンダリングされません。counterStateの値が10になったとき、roughCounterStateの値は初めて1に変化します。よって、この時初めてRoughButtonが再レンダリングされます。

このように、Atomの値を直接使わずに何らかの計算を挟んで使用する場合、その計算をSelectorとして定義することで、コンポーネントの再レンダリングを抑制できることがあります。これはReduxのselectorと同じ特徴です。

非同期なSelector

実は、Selectorの値の計算は非同期にすることもできるようです。その場合は、次のようにgetの返り値をPromiseにします。

const roughCounterState = selector({
  key: "roughCounterState",
  get: async ({get}) => {
    await sleep(1000);
    return Math.floor(get(counterState) / 10);
  }
});

このように、Selector(より一般にはState)はその値が非同期的に計算される可能性があります。useRecoilCallbackのときにgetPromiseという機能が出てきたのはこれを考慮してのことです。

そうなると、問題となるのは、まだ計算が終わっていない値をコンポーネントが使用しようとした場合です。実はこの場合はサスペンドが発生します。サスペンドはRecoilに特有の概念ではなく、ReactのConcurrent Modeの概念です。Concurrent Modeについては筆者の既存記事「Concurrent Mode時代のReact設計論」シリーズをご覧ください。

とにかく、Recoilは、ReactのConcurrent Modeを前提として非同期なSelectorにも対応しているということです。Concurrent Modeについては深入りしたくないので、非同期の話はこの記事ではあまりしません。

なお、useRecoilStateLoadableuseRecoilValueLoadableという非同期処理に関わるフックもあります。これらは、生のStateの値を取得する代わりに、そのStateのLoadableオブジェクトを取得できるものです。Loadableオブジェクトについては省略しますが、「Concurrent Mode時代のReact設計論」シリーズでFetcherと呼んでいたものと同じで、Promiseをラップしたオブジェクトです。

書き込めるSelector

RecoilのSelectorの特徴は、読み取りだけでなく書き込みもできるということです。ただしこれはオプショナルで、上記のようにgetだけで定義したSelectorは読み取り専用となります。

書き込み可能なSelectorの典型的な動作は、書き込まれたら逆計算を行いその結果を親のAtom(または別のSelector)に書き込むということです。これができることにより、RecoilにおけるSelectorは単なる計算結果という意味を超えて、Atomに対するインターフェースという意味をも備えることができます。

先ほどのroughCounterStateに書き込み対応を追加するとこうなります。書き込みは、selector関数に渡すオブジェクトにsetプロパティを追加することで行います。

const roughCounterState = selector({
  key: "roughCounterState",
  get: ({get}) => Math.floor(get(counterState) / 10),
  set: ({set}, newValue) => set(counterState, newValue * 10),
});

このように、set関数はnewValueを受け取ると共に、他のAtomに書き込むためのset関数(ややこしい)を受け取ります。このroughCounterStateは、自身に数値が書き込まれたらその10倍の値をcounterStateに書き込みます。例えば、roughCounterStateに2が書き込まれたら、counterStateには20が書き込まれます。

RoughButtonも書き込み対応にするとこんな感じになります。RoughButtonを押すとroughCounterStateの値が1ずつ増やされますから、これは一押しでcounterStateの値を10も増やせるお得なボタンとなります。

const RoughButton = () => {
  const [roughValue, setRoughValue] = useRecoilState(roughCounterState);
  return (
    <p>
      <button onClick={()=> setRoughValue(c => c+1)}>{roughValue}</button>
    </p>
  );
};

RecoilのAPIのまとめ

以上で、この記事を書いた時点でRecoilのドキュメントに乗っているAPIは説明し終わりました。

まとめると、値を保持するグローバルなステートとして使用できるAtomと、Atom(または他のSelector)から計算される値を表すSelectorが存在し、この2種を合わせてStateと呼びます3。そして、useRecoilStateなどのフックはStateを読み書きするのに使用することができます。これらのフックはStateへのサブスクリプションを暗黙に持っており、Stateの値が更新された時のみコンポーネントを再レンダリングされます。

Atomは、コンポーネント間で共有されるグローバルなステートとしてのベーシックな役割を担っています。Atom単体で見たときの利点は、グローバルなステートが沢山あった場合も、必要なAtomのみsubscribeすることができるという点です。Recoilでは、自身に関係ないAtomの値が更新されたとしてもコンポーネントが再レンダリングされることはありません。

Atomの値が更新されたときに毎回再レンダリングが発生するのは困るという場合、Selectorの出番です。Selectorは、Atomとコンポーネントの間に計算を挟むことができます。SelectorをsubscribeするコンポーネントはSelectorの値が変わったときのみ再レンダリングされますから、Atomを生で使うよりもさらに最適化された再レンダリング戦略を実現できます。

Recoilとカプセル化

これまでの例ではRecoilのAPIを生で使ってきましたが、RecoilのAPIの真価はカスタムフックの部品として使いやすい点にあります。これは、RecoilのAPIが、原始的なものであり、かつフックであるという特徴から来ています。

具体例で考えてみましょう。この記事でずっと使ってきたcounterStateの例は、ボタンを押すと必ず値が1ずつ増やされてきました(RoughButtonは例外ですが)。しかし、counterStateの定義をみてもそんなことはどこにも書かれていません。もしcounterStateの値を必ず1ずつ増やす必要がある場合、これは無防備です。ということで、実装の詳細を隠蔽することで、1ずつ増やす以外の操作を禁止しなければいけません。

普通のステートの場合

これは、普通のuseStateでは簡単にできます。こんなカスタムフックを作ればいいのです。

const useCounter = ()=> {
  const [counter, setCounter] = useState(0);
  const increment = useCallback(()=> {
    setCounter(c => c + 1);
  }, []);
  return [counter, increment];
}

このフックは数値のステートを作るフックですが、setCounterの代わりにincrementを返り値で提供します。この関数が呼び出されると、ステートは1だけ増やされます。

このステートを一気に2以上増やしたり、あるいは減らしたりするのは不可能です。なぜなら、そのために必要なsetCounterは、関数useCounter内のスコープに隠蔽されていて外から触ることができないからです。これがある種のカプセル化であり、「1ずつ増えるステート」という機能の内部実装にuseStateが使用されていることを隠蔽することで、ステートに対する変な操作を防いでいます。

グローバルなステートの場合

Recoilでも、これと同じことができます。例によって、counterStateを隠蔽して1ずつしか増やせないようにしてみましょう。すると、こんな感じになるでしょう。

const counterState = atom({
  key: "counterState",
  defalt: 0;
});

export const useGlobalCounter = () => {
  const [counter, setCounter] = useRecoilState(counterState);
  const increment = useCallback(() => {
    setCounter(c + 1);
  }, []);
  return [counter, increment];
}

先ほどと全然変わりませんね。ポイントは、useGlobalCounterにこれ見よがしに付いているexportです。これでglobalCounter.jsみたいな一つのファイルであると想定してください。すると、このファイルからエクスポートされているのはuseGlobalCounterのみであり、counterStateはエクスポートされていない、すなわちこのファイル内に隠蔽されています。

これが意味することは、useGlobalCounterを使う以外にcounterStateを使う手段がないということです。必然的に、counterStateの値はやはり1ずつしか増やせません。

このように、原始的なAPIとカスタムフックという道具によって、Stateをファイル(モジュール)のスコープの中に隠蔽する方法によるカプセル化が実現できます。これは、中央集権的なReduxには難しい芸当でしょう。

ある程度の規模のアプリの場合、RecoilのAPIをコンポーネントから直接使うよりもこのようにカスタムフックを通じて使うことの方が多いのではないかと想像できます。Recoilは、カスタムフックの利便性を完全に生かす形でグローバルなステート管理を導入できるのです。

また、改めて見てみると、このように末端のモジュールでAtomを定義するだけでそれがグローバルなステートとして有効化されるというのはとても強力ですね。Reduxのような中央集権的なステート管理ライブラリの煩雑さが大きく削減されているように思えます。

TypeScriptとの相性

最後に、RecoilとTypeScriptの相性はどうでしょうか。React自体は、公式の型定義が提供されていないとはいえ、TypeScriptとの相性は良いことが知られています。Recoil自体はFlowで型がつけられていますから、型システムとの相性は悪くなさそうですね。

まだRecoilのTypeScript型定義が無さそう(記事を書いている間に作られているかもしれませんが)なのでこれは想像なのですが、TypeScriptでRecoilのAPIに型を付ける上では特に障害はなさそうです。

例えばAtomやSelectorはそれぞれAtom<T>Selector<T>のような型を持てるでしょう。これらはatom<T>({ ... })のような形で型引数を使った関数呼び出しで作ると想像されます。ちょうど今のuseStateと同様ですね。

useRecoilStateなどのフックも、どのAtomやSelectorに依存するかを明示的に指定するAPIになっています。よって、苦労なく結果の型を得ることができるでしょう。ReduxのuseSelectorの場合ステート全体の型をuseSelectorが予め知っている必要がありましたが、Recoilではそのような苦労は必要ありません。

SelectorがAtomに依存する場合も、get(state)といった形で依存先を明示的に書きますから、get(state)の型は容易に推論されます。

このように、RecoilではAPI上で明示的に依存先を書ける(変なメタプログラミングが全く必要ない)ようになっており、TypeScriptフレンドリーに設計されています。

まとめ

この記事では、現在分かっているRecoilの特徴を整理し、筆者の所感を述べました。

Recoilはグローバルなステート管理におけるパフォーマンス上の問題を解決する事を主要な目的としており、この点はReduxと同様です。

RecoilのAPIはcode splittingが可能で、さらにカスタムフックと相性が良いシンプルなAPIとして設計されており、TypeScriptとの相性も良くなっています。

筆者としてはRecoilはかなりの高評価です。筆者もステート管理に関しては試行錯誤していましたが、これでいいのではと思わされました。


  1. 一応書いておきますが、コンセプトの話です。Reduxをフォークした訳ではありませんよ。
  2. ただし、RecoilRootというコンポーネントをアプリのルート付近に置く必要があり、ここだけ中央集権が露出しています。とはいえ、基本的にRecoilではただおまじないのようにRecoilRootを設置するだけでOKのようです。
  3. RecoilValueとも呼ばれるようです。