Reactの状態管理はReduxが有名で、大衆迎合主義の自分としては、ご多分に漏れずReduxを使っています。
ただ、Reduxを使っていてしんどいなぁと感じるところがいくつかあります。
1つは、とにかくボイラープレートが多いことです。
ちょっとしたステート(変数)の追加や修正をするだけでも、かなりの量のコーディングが必要です。
もう1つは、Reduxはあくまでステートの保持および、ステートを参照しているコンポーネントの再描画伝達までのライブラリなので、ステートを非同期に更新する機能はなく、その辺りをユーザーが自分で実装しないといけないところです。
とはいえ、ReduxはあくまでFacebookが提案した状態管理のアイディア「Flux」を実装したに過ぎず、別にReduxが悪いわけではないんですけどね。
ただ、ReduxはFacebookが作ったものじゃないし、Facebookが自社のサービスでReduxを使っているとは思えないんですよ。
Facebookはあれだけ巨大なサービスを運営している、SPAのプロ中のプロですし、Facebookが状態管理を含む、SPA全体のフレームワークを作ってくれないかなぁと思っていました。
真打ち登場
そんな中、まだベータ扱いですが、Facebookから状態管理ライブラリRecoilが登場しました。
触ってみた結論から言うと
これです!欲しかったのは!
やりたい事だけを書けばよく、うんざりするようなボイラープレートもありません。
ステートの非同期更新をはじめ、SPAでよく使われる場面が考慮されていて、必要そうな機能が提供されていました。
以下、Recoilの簡単な説明や、感想などを書いていこうと思います。
Recoilとは?
Recoilとは、Facebookが作った、Reactの状態管理ライブラリです。
atom
と呼ばれる、グローバル変数のようなものを作ることができ、そのatom
を使うコンポーネントが、atom
をサブスクライブ(参照)します。
そして、atom
が更新されると、そのatom
をサブスクライブしていたコンポーネントだけが再描画されます。
1つのatom
を複数のコンポーネントが同時にサブスクライブできるので、ステートを一箇所に集約しつつ、必要なコンポーネントだけを効率よく再描画することができます。
ちなみに、Reduxは1つの巨大なステートツリーを作成するのですが、atom
の粒度は細かく、変数・オブジェクトレベルで独立して作成されます。
atom
サンプルとして、カウンタをアップダウンするアプリを作ってみます。
import React from 'react'; import './App.css'; import { RecoilRoot, atom, useRecoilState, } from 'recoil'; const countAtom = atom<number>({ key: `countAtom`, default: 0, }); function UpDown() { const [count, setCount] = useRecoilState(countAtom); return ( <div> <button onClick={() => setCount(count - 1)}>Down</button> {count} <button onClick={() => setCount(count + 1)}>Up</button> </div> ); } function App() { return ( <RecoilRoot> <UpDown /> </RecoilRoot> ); } export default App;
パッと見でも、なんとなく何しているか分かるくらいシンプルですね。
atom作成
countAtom
という、数を覚えておくatom
を作成します。
引数にはデフォルト値と、識別キーを設定します。
Recoilの内部的にはatom
の識別は変数名ではなく、この識別キーを使っているので、キーはユニークになる必要があります。
atomをサブスクライブ
コンポーネントがatom
をサブスクライブするには、useRecoilState()
でサブスクライブするatom
を指定して行います。
すると現状のatom
の値とatom
を変更するための関数が取得できるので、後は取得した値を表示し、必要に応じて取得した関数で値を更新します。
複数のコンポーネントからサブスクライブ
先程のatom
を他のコンポーネントでも使えるようにします。
といっても、先程と同様、atom
を使いたいコンポーネント内でサブスクライブするだけです。
function ViewCount() { const [count, setCount] = useRecoilState(countAtom); return ( <div> {count} </div> ); } function App() { return ( <RecoilRoot> <UpDown /> <ViewCount /> <ViewCount /> <ViewCount /> </RecoilRoot> ); }
これだけです!
たったこれだけで、ステートを複数のコンポーネントで参照して、そのステートが変更されると、それを参照しているコンポーネントだけが再描画されるようになります。
余計な記述も必要なく、したい作業だけを記述すればOKなので、コードがスッキリしてとても見通しがいいです。
ビジネスロジックを分離する
コンポーネントがatom
を直接編集するのはあまりよろしくないので、コンポーネントからビジネスロジックを分離してみます。
これもRecoilなら簡単にできるのですが、その前にRecoil特有の注意点があります。
atomが使えるのはコンポーネント内だけ
atom
はグローバル変数のようなものと言いましたが、実は<RecoilRoot>
コンポーネントで囲まれているコンポーネント間でのみ共有されます。
しかも、atom
が使えるのは関数コンポーネントの中でのみで、関数コンポーネントの外からatom
を参照することはできません。
なので、atom
をコンポーネント外で操作するには、汎用的なロジックを関数で定義しておき、それを関数コンポーネントの内から呼び出すことにより、コンポーネント内のatom
にロジックを適用させます。
先程のUp・Downを、ロジックを分離して任意の値で行えるようにしたのが下記になります。
const addCountAtom: (a: CallbackInterface) => (a: number) => void = ({ snapshot, set }) => async (addValue: number) => { const current = await snapshot.getPromise(countAtom); set(countAtom, current + addValue); }; function UpDown() { const [count, setCount] = useRecoilState(countAtom); const addCount = useRecoilCallback(addCountAtom); return ( <div> <button onClick={() => addCount(-3)}>Down</button> {count} <button onClick={() => addCount(3)}>Up</button> </div> ); }
addCountAtom()
がロジック部分で、現在のatom
値を取得して、引数の数だけ足した値をatom
にセットしています。
これをuseRecoilCallback()
に渡すことにより、コンポーネントのatom
を使って関数が実行されるようになります。
すごく長く見えますが、TypeScript型定義で長く見えているだけで、実態は2行だけです。
selector
Recoilにはatom
に加え、selector
という値もあります。
selector
はそれ自身は値を保持せず、他のatom
から計算によって求めた値を返します。
元となるatom
が更新されると、selector
値も再計算されます。
例えばカウントを100倍にした値を取るselector
を作りそれを表示してみます。
const countSelector = selector<number>({ key: `countSelector`, get: ({ get }) => get(countAtom) * 100, }); function ViewCount100() { const count = useRecoilValue(countSelector); return ( <div> {count} </div> ); } function App() { return ( <RecoilRoot> <UpDown /> <ViewCount100 /> <ViewCount100 /> <ViewCount100 /> </RecoilRoot> ); }
selector
は変更する値が無いので、変更関数を取得しないuseRecoilValue()
を使って値を取得します。
非同期更新
次に、値を非同期更新する方法を見てみます。
atomの非同期更新
1つ目はatom
の更新で、前述のビジネスロジックの箇所です。
といっても、async/await
が使われていることから分かるように、これ自身非同期関数なので、特に意識しなくてもそのままawait
で非同期処理が書けます。
例えば、1秒遅れて更新するロジックは下記になります。まんまですね。
const addCountAtom: (a: CallbackInterface) => (a: number) => void = ({ snapshot, set }) => async (addValue: number) => { const current = await snapshot.getPromise(countAtom); await new Promise((resolve) => { setTimeout(() => resolve(), 1000); }); set(countAtom, current + addValue); };
selectorの非同期更新
もう一つはselector
の更新で、selector
の定義のget()
関数をasync
にすれば、データ取得に非同期処理が書けます。
例えば、1秒遅れて更新されるselector
は下記になります。これもまんまですね。
const countSelector = selector<number>({ key: `countSelector`, get: async ({ get }) => { const count = get(countAtom); await new Promise((resolve) => { setTimeout(() => resolve(), 1000); }); return count * 100; } });
ただ、1点だけ注意があります。await
で非同期処理する前に、全てのget()
を呼び出して値を取得しておかないと無限ループでハマります。
更新待ち処理
ステートが非同期で更新される時、更新されるまでの間、ウエイトの表示など、ユーザーに何かしらの別表示をさせることはよくあります。
これにもRecoilはデフォルトで対応しています。
先程のselector
の非同期更新の場合、そのselector
を使うコンポーネントを<Suspense>
で囲うことにより、selector
の値が更新されるまでの間は、<Suspense>
が表示されるようになります。
そして、値が確定すると<Suspense>
ではなく、本来のコンポーネントが表示されるようになります。
function App() { return ( <RecoilRoot> <UpDown /> <Suspense fallback={<div>Loading...</div>}> <ViewCount100 /> <ViewCount100 /> <ViewCount100 /> </Suspense> </RecoilRoot> ); }
ただ、この機能は「<Suspense>
で囲います」だけの簡単な話ではなく、あくまで現行バージョンのReactでできる最大限のことであって、裏では将来のReactで導入予定の「Concurrent Mode(並列モード)」に沿った仕様で動いています。
Concurrent Mode(並列モード)とは?
Concurrent Mode(並列モード)とは、将来Reactに導入される予定の、新しい描画モードです。
Reactの描画は、表示するデータが全て決まってから描画する、同期的な処理です。
データ取得中のウエイト表示処理は、事前にデータの状態を把握して、ウエイト表示を含む、その時点での表示内容を決めてから描画するということを、ユーザー独自に実装する必要がありました。
Concurrent Modeをざっくり言うと、表示するデータに「確定」「取得中」「取得失敗」の状態を持てるようにして、描画時にそれらの状態によって表示出し分けできるようにした、Reactの新しい描画モードです。
これをReactが標準機能として持つことにより、今まで面倒だったウエイト処理が、Reactのお作法に従って書くだけで簡単に実装できるようになります。
実はRecoilのデータは、上記の状態を持ったデータで、来るべきConcurrent Modeの時代にそのまま活躍できるよう設計されています。
以上が、Recoilの簡単な紹介になりますが、Recoilはシンプルで直感的で使いやすく、まさに欲しかったものでした。
Facebookの本気を見た気がした
以前、React Hooksがリリースされた時に、新しい状態管理機能が出たのかと期待したのですが、「関数コンポーネントにステータスを持たせられるようにしただけか」とガッカリしたことがありました。
その時、既にクラスコンポーネントがあるのに、あえて何故このタイミングに関数コンポーネントなの?と思ったのですが、今回Recoilを触ってみて、むしろRecoilを実現するために、React Hooksを作ったのではと感じたほどです。
今まで、何でFacebookはFluxを提案しておきながら、本格的な状態ライブラリを作らないのか疑問だったのですが、その理由が分かったような気がしました。
従来のReactの延長では限界があり満足のいくものが作れないので、これまでの資産を捨てて、新しい機能を追加してまでして、Recoilを投入してきたように思えます。
そして、その改革の範囲は、関数コンポーネントだけにとどまらず、Concurrent Modeという、Reactの描画の根幹にまで及んでいます。
確かにこれらが揃えば、SPAで想定される大方の処理は、ユーザー独自の実装に頼ることなく、一連の機能を使うだけで実装できそうです。
今後もブラウザでのアプリ開発は「HTML+JavaScript」の時代が続きそうなことを考えると、安価なライブラリを提供するより、これまで築き上げてきたもをの壊してでも、使いやすいSPAライブラリ郡を作ろうというFacebookの本気を見た気がしました。
Reactを変更するのはFacebookにしかできないことなので、そこまでやってもらえるなんて嬉しい限りです。
感想など
ここには書ききれませんでしたが、Recoil・React Hooks・Concurrent Modeには、他にもSPAサイトを作るにあたって使いそうな機能が色々あって、サイト制作の視点でよく考えられてるなぁと思いました。
まだ発展途上ですが、Recoilは現行のReactでも十分快適に使えるかと思います。
今までは、何かちょっとしたサイトを作る際、本当はReactを使ってサクッとサイトを作りたかったのですが、そのコーディング量の多さに躊躇していました。
関数コンポーネントが主流になって随分コードがスッキリしましたし、これからはRecoilでサクッとサイトを作っていきたいですね!