React界隈で大注目のRecoilについて、先日紹介記事を書いた藤田です。
Recoilに新しいパッケージRecoil Syncが加わったので、簡単にご紹介します。

目次
軽くおさらい:コンポーネントの状態を管理する最もシンプルな方法=useState
React hooksで最も基本的なuseStateは、コンポーネント中に閉じた状態を手軽に定義する方法でした。
const MyComponent: React.FC<{defaultName:string}> = ({defaultName}) => {
const [name, setName] = React.useState(defaultName)
:
:
このようなコードで、コンポーネント中で変更できる値”name”が使えて、初期値はコンポーネント引数のdefaultNameとなります。
Recoilでコンポーネントをまたいだ状態を実現する方法=Atom+useRecoilState
RecoilはReact開発元のMetaが開発中の状態管理ライブラリで、とても簡潔なコードでコンポーネントをまたいだ状態オブジェクトを作ることができます。詳しくはこの記事冒頭のリンクから先日書いた記事をご参照ください。
※ 2022年7月現在、Recoilはexperimentalフェーズのまま機能追加が進んでいます。
RecoilではAtomという単位で状態を保持しています。このAtomはコンポーネントの外側でしか定義できないので、初期値は静的な値のみが許されます。
// コンポーネントの外側でAtomを定義します
const nameAtom = atom<string>({
key: 'nameAtom',
default: '' // Atomの初期値は静的な値のみ
})
const MyComponent: React.FC<{defaultName:string}> = ({defaultName}) => {
// Atomの値とsetterを取得するコードはuseStateとそっくり!
const [name, setName] = useRecoilState(nameAtom)
:
:
これだけで大変便利ですが、useStateと比べたときに最初に気がつく大きな違いは、初めてAtomを使う時にコンポーネントから初期値を渡せないことです。
次のようなコードは書けません。
const MyComponent: React.FC<{defaultName:string}> = ({defaultName}) => {
// もしコンポーネント中でatomを定義するとエラーになります
const nameAtom2 = atom<string>({ // ここでエラー発生
key: 'nameAtom2',
default: defaultName // 渡したいけど渡せない
})
:
:
Atomに初期値を与える方法(before:useEffect)
どうしてもAtomに初期値を入れたい場合は、useEffectでセットするしかありませんでした。
const MyComponent: React.FC<{defaultName:string}> = ({defaultName}) => {
const [name, setName] = useRecoilState(nameAtom)
React.useEffect(() => {
// 初回レンダリング時に
// 引数の値をAtomにセット
setName(defaultName)
}, [defaultName])
:
:
しかし、これでは「Atomに初期値defaultNameを与えたい」という目的に対して「defaultNameが前回と変化していればAtomを更新する」というコードを書いていることになります。目的と実装がちょっとずれていて、意図が伝わりにくいですね。
しかも、useEffectでAtomの値を更新しているので、MyComponentのレンダリング後、もう1回MyComponent関数が実行されるのも嬉しくないです。
ちなみにAtomにミュータブルな値やPromiseを入れておいてコンポーネント関数中から変更…とか考えるだけでゾッとするような手段は、もしかしたら可能かもしれませんがやっちゃ駄目なので試してません。
Recoil Sync
Recoil SyncはRecoil開発チームがRecoilと同じリポジトリで開発していて、つい先月の2022年6月にバージョン表記からalphaが取れて0.1.0となったばかりのパッケージです。
$ npm install recoil-sync
で使い始められます。
概要
- Atom初回参照時
- Atomが更新された時
のタイミングで、コンポーネント内に記述した関数を実行する仕組みです。(従来はコンポーネント外にしか書けませんでした)
ユースケース
- コンポーネント引数からAtomに初期値を渡す
- Atomの読み書きを外部DB等に同期させ、ブラウザセッションをまたいで値を保持する
- Atomの読み書きをAPI経由で外部システムに同期させる
といった用途に使えます。
構成要素
- Atom利用側のコードを汚さずにAtomに副作用をもたせるAtom Effects
- Atomの値にバリデーションをかけたり更新したりするRefine
Recoilに最近追加された上記の仕組みを利用して実現されています。
この記事で全部解説すると長いので、コンポーネント引数からAtomに初期値を渡すというケースだけに絞ってご紹介します。
Atomに初期値を与える方法(after:Recoil Sync)
では、Recoil Syncを使ってコンポーネント引数からAtomに初期値を渡す最小限のコードを3ステップで書いてみましょう。
STEP1. Atomを宣言(syncEffect())
コンポーネント外でAtomを宣言し、effectsというプロパティを新たに加えます。
import { CheckSuccess } from '@recoiljs/refine'
import { RecoilSync, syncEffect } from 'recoil-sync'
const nameAtom = atom<string>({
key: 'nameAtom',
// 結果的にはこのdefaultは意味なくなる
default: '',
effects: [
syncEffect({
storeKey: 'nameAtomStore',
refine: (value): CheckSuccess<string> => ({
value,
type: 'success',
warnings: []
})
})
]
})
syncEffectはRecoil Syncが提供するAtom Effectで、後述の<RecoilSync>とつなぐ働きをします。
storeKeyは<RecoilRoot>と対応付けるために指定する検索用文字列です。
refine関数は必須で省略できません。本来はDB等から取得した値のバリデーションチェックや変換に使われるのですが、ここでは引数で受けたvalueをそのまま素通しする最小限の実装としました。
他にread、 write、listenといった関数を指定でき、Atomに対する初期値要求時や変更時の副作用を記述できます。ここでは与えられた初期値をそのままスルーするだけなので省略しています。
STEP2. コンポーネントを2重にして<RecoilSync>タグを使う
// 引数defaultNameをAtomの初期値にしたい!
const MyComponent: React.FC<{defaultName:string}> = ({defaultName}) => {
return (
<RecoilSync
storeKey="nameAtomStore"
// Atomの初期化時にこの関数が呼ばれるので
// コンポーネント引数の値を返せば…
read={() => defaultName}
>
<Inner />
</RecoilSync>
)
})
Atom宣言時に設定したstoreKeyと一致する<RecoilSync>が利用され、read関数がAtomの初期化時に呼ばれます。本当はitemKeyというstringが渡されるのですが、最低限の実装ではこうなります。
STEP3. 「内側」コンポーネントで普段どおりにAtomを利用
const Inner: React.FC = () => {
// ちゃんと初期値を受け取れる!
const [name, setName] = useRecoilState(nameAtom)
:
:
ちょっと長いですが…あと、これだけのためにコンポーネントを2重化していますが…うん、まあ仕方ないと思うことにしましょう。
初期値の読み込みは副作用の一種なので、Atom呼び出し側コードから副作用を切り離す設計意図上、<RecoilSync>タグはどうしてもuseRecoilStateを呼んでいるコンポーネントより親の階層にある必要があるのだと思われます。
atomFamilyにも初期値を与えられる!
前回のRecoil紹介記事でも触れましたが、URLパラメーターなどでIDが与えられ、そのIDをもとにAPI経由で取得したオブジェクトをAtomに保存する、といったケースではatomFamilyを使うのが便利でした。
このatomFamilyもRecoil Syncで初期値を与えられます。atomFamilyはIDが変わるたびに初期値を要求してread関数を呼びます。その後は普通のAtomと変わりません。
// IDとしてbookIdを取るBook型のAtom
const bookAtom = atomFamily<Book, string>({
key: 'nameAtom',
default: '',
// atomFamily参照時のIDはここに渡ってくる。
effects: (bookId:string) => [
syncEffect({
storeKey: 'nameAtomStore',
refine: (value:string): CheckSuccess<string> => ({
value,
type: 'success',
warnings: []
}),
// bookIdをitemKeyとして渡す
read: ({read}) => read(bookId)
}),
]
})
<RecoilSync>の宣言が少し変わります。
<RecoilSync
storeKey="nameAtomStore"
read={(bookId:string) => {
// 初期化時に毎回呼ばれる。
// itemKeyとしてbookIdが渡ってくるので
// どうにかしてBookオブジェクトを返す
:
return ......
}}
>
URLとの同期
もう一つ重要な機能として、URLが変化した時にAtomと外部DBを同期するというのがあります。
<RecoilSync>タグの代わりに<RecoilURLSyncJSON>タグを使うと、URLが変化したタイミングでAtomの初期化、外部DBとの同期ができるようになるのです。これも使いでがありそうですね。
Recoilはいつまでexperimentalなのか?
多くの人に待望されているRecoil正式リリース
本記事でご紹介したRecoil Syncはまだできたばかりですが、大元となるRecoilは2020年6月の初回リリースからは2年以上経ち、バージョンももう0.7.4まで上がりました。
しかし、2022年7月現在、RecoilはまだMeta Experimentalで開発されています。使っていいのか不安になる方もいるでしょう。
GithubリポジトリにもたびたびIssueが立てられて、experimentalでなくなるのはいつなのかたくさんの人が質問しています。
中の人曰く、Meta社はRecoilをすでにプロダクションで使っているが、React18で追加されたConcurrent Rendering、Server Components、Streaming SSRなど、React本体の大きな新機能すべてにRecoilが互換性を保てると確信が持てるまではexperimentalのままでいたいという方針だそうです。
個人的にはRecoilを使うのは「あり」
僕個人の意見としては、将来に渡ってメンテしていくようなプロダクトであればRecoilを使うのは全然ありだと思っています。
まず第一に、ソースコードの読みやすさ、書きやすさ、構成のシンプルさにもたらすRecoilのメリットは明らかで、もうReduxに戻れません。
第二に、Recoilの基本機能はすでに安定しています。バージョン0.7.4に至った2022年7月現在ではAtomやSelectorといった中心的なAPIの破壊的変更はほぼありませんし、すでに世界中で多くの人がプロダクションに使っていて特に大きな問題は見つかっていません。
現在バージョン18のReact本体が19になるころにRecoilのexperimentalが取れる、APIが大変更される、あるいはReactに取り込まれる、などの大きな変化があったとして、どうせReactやその周辺ライブラリの変化にも対応せざるを得ないことを考えると、もうRecoilも今から使っておけばいいのではないかという気になっています。
Recoil Syncはちょっと「待ち」か?
ただし、Recoil Sync(とAtom Effects、Refine)はまだリリースされたばかりですし、今後短いスパンで利用者のフィードバックによりAPIが大きく変化する可能性は高いです。それほど大規模な仕組みでもないので、小さいプロジェクトであればそのあたりの可能性を吟味した上で試してみるのもいいかもしれません。
ブログリレー!
この記事は「2022年 ヌーラボ真夏のブログリレー」7月15日分です。よかったら他の記事もご覧ください。
(アイキャッチ画像:Free Stock photos by Vecteezy)