aptpodフロントエンドエンジニアの黒川です!
aptpod Advent Calender2020の19日目を担当します。
2020年は新型コロナウイルスの世界的流行により全てが一変した年でした。
オリンピックも延期になりましたし、私達の生活様式や働き方、価値観まで変わりました。
そんな2020年にReactの状態管理を大きく変えるライブラリがリリースされました。それがRecoilです。
Recoilについては、私の以前書いた記事でも名前だけ触れました。
2020年の5月に行われたReact Europe2020で発表され、瞬く間に注目を浴びまして、2020年12月現在GitHubスター数1万を超えるなかなかの人気ライブラリとなっております。
とはいえ、npm trendsなどを見ても、同じく状態管理ライブラリであるReduxやMobXには大きく水をあけられており、まだまだ実際に使われている機会は少ない、これからのライブラリです。
今回は、そんなRecoilについて基本的な使い方と思想、そしてこういう使い方をすると嬉しいんじゃないかというお話をしたいと思います。
Recoilについて
Recoilは2020年5月にリリースされたReact用の状態管理ライブラリです。
2020年12月現在はまだexperimentalということもあり、まだまだ製品へと本格投入されるフェーズにありません。しかし、その利便性やReactの本家本元であるFacebookが開発しているという話題性から注目を集めています。
Recoilのコンセプトは非常にシンプルです。atom
と呼ばれる関数から生成されたRecoilState
というRecoil専用の状態をそのままコンポーネントにsubscribeさせるか、あるいはselector
と呼ばれる純粋関数を通してコンポーネントにsubscribeさせるか、これだけです。Reduxのようなreducerやactionといった多くのボイラープレートをRecoilは必要としません。
これはRecoilの開発が、Reactの元々持つ状態管理(useStateやContext)のシンプルさと便利さを損なうことなく、逆にそれらを扱う上で制約となっていた不自由さを取っ払うことを目的に行われていることによるものです。以下に実際に従来のコードとRecoilを用いたコードを書いたので見比べてみましょう。
// 従来の書き方 import React, { useCallback, useState } from "react"; export const Conventional = () => { const [count, setCount] = useState<number>(0); const onIncrement = useCallback(() => { setCount((prev) => prev + 1); }, []); const onDecrement = useCallback(() => { setCount((prev) => prev - 1); }, []); return ( <div> <p>{count}</p> <button onClick={onIncrement}>インクリメント</button> <button onClick={onDecrement}>デクリメント</button> </div> ); };
// Recoilを用いた書き方 import React, { useCallback } from "react"; import { useRecoilState } from "recoil"; import { counterState } from "../../atoms/state"; // counterStateはこのような定義がされています // const counterState = atom<number>({ // key: "counterState", // default: 0 // }); export const RecoilWay = () => { const [count, setCount] = useRecoilState(counterState); const onIncrement = useCallback(() => { setCount((prev) => prev + 1); }, []); const onDecrement = useCallback(() => { setCount((prev) => prev - 1); }, []); return ( <div> <p>{count}</p> <button onClick={onIncrement}>インクリメント</button> <button onClick={onDecrement}>デクリメント</button> </div> ); };
このようにほとんど変わりません。
変更点は、Recoilの方でimportしているatom
関数によって定義されたcounterState
がuseState
関数と入れ替わったuseRecoilState
関数によってsubscribeされたことだけです1。
これだけで今やRecoilで定義されたほうのcount
は、他のコンポーネントからもcounterState
をsubscribeすることで参照可能となったのです。簡単で便利ですね!
ここではRecoilが既存のReactのシンプルさを引き継ぎつつ、簡単に他のコンポーネントからもRecoilState
が参照可能になることを紹介しました。続いて、Recoilの代表的なAPIと基本的な使い方を紹介したいと思います。
Recoilの代表的なAPI
atom
atom
はRecoilで用いられるRecoilState
を生成する関数で、使い方は非常にシンプルです。以下のようにatom
関数で宣言するだけで完了です。
const counterState = atom<number>({ key: "counterState", default: 0 });
ほとんど初見でも分かりそうなくらいにシンプルですが、簡単に説明します。
atom
はkey
とdefault
という2つのプロパティを引数として求めます。key
はアプリケーション全体でユニークなものを渡す必要があります。これは内部的にkey
によってatomを判別しているからです。default
はuseState
やReduxにおける初期値になります。
以上を設定すれば、あとはこのatom
から生成されたStateを使用するコンポーネントにおいてsubscribeすれば参照可能になります。
アプリケーションを通してこのatom
は何度も使用することになるかと思います。
そのような時に、例えばユーザーネームとパスワードなど同じinput
を用いたコンポーネントでありながら、別々のRecoilState
を扱うコンポーネントに対してuserNameState
, passwordState
などといちいちatom
からRecoilState
を生成するのは面倒ですよね。
そんな時は、atomFamily
を使えば解決です。atomFamily
は動的にatomを作成してくれる関数であり、宣言もほとんどatom
と同様です。
import React from "react"; import { useRecoilState, atomFamily } from "recoil"; const inputTextState = atomFamily<string, string>({ key: "inputTextState", default: "" }); export const InputFamily = () => { // もしパラメータを同じものにすると変数名が違っても、同じ値になる const [userName, setUserName] = useRecoilState(inputTextState("userName")); const [password, setPassword] = useRecoilState(inputTextState("password")); const [remarks, setRemarks] = useRecoilState(inputTextState("remarks")); return ( <> <input value={userName} onChange={(e) => setUserName(e.target.value)} /> <input value={password} onChange={(e) => setPassword(e.target.value)} /> <input value={remarks} onChange={(e) => setRemarks(e.target.value)} /> <p>{userName}</p> <p>{password}</p> <p>{remarks}</p> </> ); };
ほとんどatom
を用いた宣言と同様な事がわかります。異なる点は、RecoilState
をsubscribeする際に任意のパラメータをatomFamily
に渡すことです。ちなみにatomFamily
の型引数の1つ目はStateの型で、2つ目はパラメータの型になります。
これにより内部的にatomFamily
が生成したatom
にkey
をマッピングしてくれるのでこちらで細かいことを気にする必要はありません。
今回はinputで簡単に説明しましたが、例えばユーザーが動的に追加していく項目やボタンなどにもatomFamily
は用いることができますので、非常に活用の幅は広いです。
selector
selector
はatom
で生成されたRecoilState
をコンポーネントにsubscribeする際、使いやすい形に加工するための関数です。
まずはサンプルコードをお見せして説明しようと思います。こちらはRecoilの公式ドキュメントから引用しました。
const tempFahrenheit = atom({ key: 'tempFahrenheit', default: 32, }); const tempCelcius = selector({ key: 'tempCelcius', get: ({get}) => ((get(tempFahrenheit) - 32) * 5) / 9, set: ({set}, newValue) => set( tempFahrenheit, newValue instanceof DefaultValue ? newValue : (newValue * 9) / 5 + 32 ), });
selector
を使うときには、2つのプロパティを定義する必要があります。1つ目はおなじみのkey
ですね。ユニークなものを定める必要があります。
2つ目は、get
です。ここでコールバック関数を定めることでselector
がどんな加工を行うか定義できます。また、このコールバック関数は引数にget
という関数を受け取れます。このget関数にatom
で定義したRecoilState
を渡すことでselector内で値を読み込むことができます。ここからは自由に加工可能です。上の例では、温度の単位変換として華氏(°F)の値を読み込んで摂氏(℃)に変換しています。
さて、2つのプロパティと言いましたが、上記の例では3つ定義していますね。3つ目のプロパティset
はオプションです。定義せずに読み込み専用のselector
とすることも勿論可能ですが、定義することでselector
とatom
の値を連動させることができます。
set
の役割は、RecoilState
を新しい値に上書きすることです。get
プロパティ同様にset
プロパティもset関数を引数に受け取ります。また、上書きするための新しい値も受け取ります。このset関数の第1引数に更新対象であるRecoilState
、そして第2引数にset
プロパティが受け取った新しい値を渡すことで、RecoilState
が更新されます。上の例ではtempFahrenheit
が更新の対象です。また、tempCelcius
はtempFahrenheit
の値をもとに生成されているので、こちらも連動して更新されます。このようにselector
でRecoilState
を更新させた際は、結果的にそのselector
のget
から得られる値も更新されることとなります。
また、上記のコードを見るとset関数の新しい値をDefaultValue
のインスタンスかどうかを判別しているかと思います。これは、useResetRecoilState
というRecoilState
の値をdefaultに戻すHooksの使用を考慮したものです。すなわち、useResetRecoilState
でnewValue
がDefaultValue
のインスタンス = defaultの値の時はそのままdefaultの値に更新し、それ以外の時は摂氏を華氏に変換して更新しています。文字で説明すると少々ややこしいですが、実際に使ってみるとわかりやすいAPIです。
さらにselector
は非同期処理も組み合わせることが可能です。
これを利用して以下のように、例えばあるidのデータをAPIを通して取り出したいというものが簡単に実装できるようになります。
const asyncSelector = selector({ key: "asyncSelector", get: async ({ get }) => { const res = await getSomething(get(idState)); return res.data; } });
ただし、非同期処理にselector
を用いる時は一点だけ注意が必要です。
このasyncSelector
から返ってくる値は非同期であるため、同期的にコンポーネントをマウントした場合はまだ取得されていないデータを参照する可能性があり、アプリケーションの動作に支障を来してしまうということです。これを回避するための方法は2つあります。
1つ目はコンポーネント内は同期的に取得して、親コンポーネントで回避する方法です。以下のコードをご覧ください。
// 親コンポーネントで回避するパターン const SomethingAsync = () => { const value = useRecoilValue(asyncSelector); return <div>{value}</div>; }; const ParentComponent = () => { return ( <Suspense fallback={<div>Loading...</div>}> <SomethingAsync /> </Suspense> ); };
ここではSuspense
を使っています。Suspense
について簡単に説明しますと、SomethingAsync
が読み込まれるまで待機するためのコンポーネントです。Suspense
のfallback
に渡したコンポーネントを待機中に表示してくれますので、非同期中であってもアプリケーションは正常に動作できます。
もう1つは、コンポーネント内で回避する方法です。この回避をするためのHooksとしてRecoilはuseRecoilValueLoadable
を提供しています。
このuseRecoilValueLoadable
は値をそのまま返すのではなく、Loadableオブジェクトというものを返します。Loadableオブジェクトはstate
とcontents
という2つのプロパティを持ち、contents
はstate
の状態によって中身が変わります。
以下のコードを見て、state
とcontents
がどのような形になりうるか確認したいと思います。
// コンポーネント内で回避するパターン const SomethingAsync = () => { const valueLoadable = useRecoilValueLoadable(asyncSelector); switch (valueLoadable.state) { // 非同期処理が終わっていない時、stateはloadingとなり、contentsはPromiseとなる // そのままだと表示できないので、<Suspense>と同じ扱いをする case "loading": return <div>Loading...</div>; // 非同期処理が終わればstateはhasValueとなり、 // contentsには実際の値が入るので、コンポーネント内で使用できる case "hasValue": return <div>{valueLoadable.contents}</div>; // もしも非同期処理中にエラーが生じるとstateはhasErrorとなる // contentsにはErrorオブジェクトが入るので、これをthrowすることでErrorBoundaryに検知させる case "hasError": throw valueLoadable.contents; } };
このような感じです。実際にどちらのパターンを取るかは好みやコンポーネントの設計によると思いますが、selector
で非同期処理を扱う時はこのような処理が必要ということだけ留意する必要があります。
useRecoilState/useRecoilValue/useSetRecoilState
今まででRecoilを扱うためのStateを作るAPIを紹介してきました。ここからは実際にコンポーネントにsubscribeするためのHooksを紹介していきます!
ただ、実はRecoilでsubscribeするためのHooksはほとんど中身は一緒なんです!
違いは、
- 「値」と「更新するための関数」両方必要か
- 「値」だけ必要か
- 「更新する関数」だけ必要か
の3つに応じて使い分けるだけです。
そして、それぞれ①のケースがuseRecoilState
、②がuseRecoilValue
、③がuseSetRecoilState
となります。
すなわち、useRecoilValue
+ useSetRecoilState
= useRecoilState
です。簡単ですね。
useRecoilState
は今までのサンプルコードで何度も用いてきましたが、以下に3つのサンプルコードを書きます。
Reactのhooksに慣れている方であれば、全く違和感なく書けると思います。
// ReactのuseStateと全く一緒です const [count, setCount] = useRecoilState(counterState); // ReactのuseStateからstateだけを取り出したものです const count = useRecoilValue(counterState); // ReactのuseStateからsetStateだけを取り出したものです const setCount = useSetRecoilState(counterState)
いかがでしょうか。本当にシンプルなAPIなのでありがたいですね。
さて、ここまででRecoilState
を作り、それをコンポーネントにsubscribeさせる方法を説明してきました。これによりもう<RecoilRoot>
内のどこからでも自由にRecoilState
を参照できるようになっています。ただ、まだ気になる点が1つあります。onClick
などイベント発火時のコールバック関数です。従来のuseCallback
は便利ですが、下手に依存関係を持たせてしまうと依存している変数の更新に伴い、useCallback
の更新も行われ、意図しない再レンダリングが生じていました。RecoilState
が更新されても不要な時はsubscribeせず、必要になった時にRecoilState
を読み込む、そんなコールバック関数があれば理想的です。そのような希望を叶えてくれるのが次に紹介するAPIです。
useRecoilCallback
Recoilにはsnapshot
というRecoilの現在のStateを読み取れるオブジェクトがあります。useRecoilCallback
はこのsnapshot
を用いて、必要な時にだけStateを読み込み発火することができます。この必要な時に読み込むことの何が嬉しいのかは、上述の通り不要な再レンダリングを避けられることです。
この再レンダリングを説明するために、姓と名を入力して送信ボタンを押すと、フルネームが表示される簡単なアプリケーションのコードをReactのAPIのみで書きました。
import React, { useCallback, useState } from "react"; type InputProps = { value: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; }; const Input = (props: InputProps) => { return <input value={props.value} onChange={props.onChange} />; }; type ButtonProps = { onClick: () => void; }; const Button = (props: ButtonProps) => { return <button onClick={props.onClick}>送信</button>; }; export const Form = () => { const [lastName, setLastName] = useState<string>(""); const [firstName, setFirstName] = useState<string>(""); const [fullName, setFullName] = useState<string>(""); const onChangeFirstName = useCallback( (e: React.ChangeEvent<HTMLInputElement>) => { setFirstName(e.target.value); }, [] ); const onChangeLastName = useCallback( (e: React.ChangeEvent<HTMLInputElement>) => { setLastName(e.target.value); }, [] ); const onClick = useCallback(() => { setFullName(`${firstName} ${lastName}`); }, [firstName, lastName]); return ( <div> <Input value={firstName} onChange={onChangeFirstName} /> <Input value={lastName} onChange={onChangeLastName} /> <p>FullName: {fullName}</p> <Button onClick={onClick} /> </div> ); };
こちらのGIFでわかりますように、Inputの文字を変更するたびにInputそのものとForm、そしてButtonに再レンダリングがかかっています。(白い明滅がレンダリングがかかっているということです)
これはInputの変数をButtonに渡すために、その親コンポーネントであるFormがInputの変数も持っているため、lastName
やfirstName
更新時にInputだけではなくFormまで再レンダリングがかかるためです。また、Buttonに降ろすコールバックもlastName
とfirstName
に依存しているため、このコールバックを使用するButtonにも再レンダリングがかかります。勿論、いくつかの工夫により多少は抑制されますが、やはりレンダリングは必要なコンポーネントに留め、不要な再レンダリングは可能な限り避けたいものです。
これを解消するのがRecoil、そしてuseRecoilCallback
になります!同じような構成をRecoilで書き直してみたのが以下になります。
import React, { useCallback, useState } from "react"; import { atomFamily, useRecoilState, useRecoilCallback } from "recoil"; const nameState = atomFamily<string, string>({ key: "nameState", default: "" }); type Props = { param: string; }; const Input = (props: Props) => { const [value, setValue] = useRecoilState(nameState(props.param)); const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { setValue(e.target.value); }, []); return <input value={value} onChange={onChange} />; }; type ButtonProps = { onClick: () => void; }; const Button = (props: ButtonProps) => { return <button onClick={props.onClick}>送信</button>; }; export const RecoilForm = () => { const [fullName, setFullName] = useState<string>(""); const onClick = useRecoilCallback( ({ snapshot }) => async () => { const firstName = await snapshot.getPromise(nameState("firstName")); const lastName = await snapshot.getPromise(nameState("lastName")); setFullName(`${firstName} ${lastName}`); }, [] ); return ( <div> <Input param={"firstName"} /> <Input param={"lastName"} /> <p>FullName: {fullName}</p> <Button onClick={onClick} /> </div> ); };
いかがでしょうか。明滅が変更のかかっているコンポーネントのみに留まりましたね!
InputにはatomFamily
でnameState
というものを作り、複数のInputであっても1つのRecoilState
で対応できるようにしました。
そして、RecoilFormでは自身が使うローカル変数であるfullName
をuseState
で作り、Buttonに渡すコールバックはuseRecoilCallback
で作成しています。
useRecoilCallback
は高階関数のような形をとっていまして、冒頭で説明したsnapshot
オブジェクトは1つ目の引数に受け取ります。このsnapshot
から他のatom
のRecoilState
を読み込むことができるのですが、ここで注意してほしいのはsnapshot
からはPromiseの形でしか読み込めないことです。なぜPromiseかといいますと、非同期selector
を考慮した設計だそうです。そして、Promiseを扱うので2つめの引数にはasyncを付ける必要があります。
コールバック内ではsnapshot
オブジェクトのgetPromise
という関数に他のAPI同様にRecoilState
を渡すとその値を読み込むことができます。これにより、useCallback
のように再レンダリングをかけることなくInputの値を取り扱うことができます!
一点注意してほしいのは、今回はuseState
の更新を行うためにsetState
をuseRecoilCallback
内部に置きましたが、もしRecoilの値を更新したい時はuseSetRecoilState
を用いてはいけないということです。したがって、useRecoilCallback
はselector
同様にset
関数を用意しています。使い方もselector
と一緒で第一引数に更新したいRecoilState
、第2引数に新しい値を指定します。useRecoilCallback
を用いる時にRecoilState
を更新したい場面は多いと思いますので、この点だけ気をつけてください。
まとめ
今回はRecoilのAPIを中心にどのような点が嬉しいのか、どのような点に気をつければいいのかを中心に書かせていただきました。
Recoilは、私自身も書いていてとても書き心地がいいですし、便利なのでもっと広まればいいなっと思っています。今回は書ききれませんでしたが、便利なAPIや機能はまだまだたくさんあります。
一方で、今回は伝えきれなかったのですが、atom
などは油断するとすぐに無秩序になりがちなので治安の良い書き方というのも意識する必要がありそうです。このあたりのベストプラクティスはまだまだ溜まっていないので、これからに期待ですね。
また、Facebook Experimentalということで、まだまだRecoil自体もどうなっていくか分かりません。最初のリリースからAPIも変わったりしていますので、製品にホイホイと投入できるほど安心はできないというのも事実です。この点についても趨勢を見守っていきたいですが、2021年には何らかの進展が見られることを期待して、本タイトルをつけさせていただきました!2021年に活用していきたいです!(願望)
さて、来週月曜日はハードウェアグループおおひらさんの「非接触CANセンサーで車両の制御信号を可視化してみた」になります。
自動車の非接触センサーを用いてリアルタイムにちゃんと可視化できるかを検証する弊社らしい記事になっておりますので、是非こちらもご注目ください!
<参考文献>
Recoil公式サイト https://recoiljs.org/recoiljs.org
-
正確にはRecoilを使用しているコンポーネント群のトップに
<RecoilRoot>
を置く必要があります。 ↩