はじめに
Reactの状態管理ライブラリとしてMetaが開発しているRecoilがあります。Recoilは宣言的という点に重きにおいて開発している点や、Reactが提供するhooks APIのような書き心地ですので普段Reactを利用するユーザーにとってはとても親しみやすく感じるライブラリです。
この記事ではそんなRecoilで非同期データを扱う方法を紹介します。Recoil自体については以前記事を書いたのでこちらも合わせてご覧ください。
基本のおさらい
Recoilはatom
とselector
の二つの概念によって状態を構築します。
atom
atom
はコンポーネントがサブスクライブする単位です。これ以上分離できない状態ということなので、命名にふさわしい性質を持っていますね(原子は陽子、中性子、電子からなっていてそれらもクォークなどの細かい粒子で構成されるというのは置いておいてある程度巨視的な視点においてです)。
const countState = atom<number>({
key: 'count',
default: 0,
});
selector
selector
はatom
もしくは別のselector
を利用して作られる状態です。構成する関数は純粋関数である必要があり、利用している状態が更新されるとselector
も更新されます。selector
をサブスクライブしたコンポーネントはそのselector
自身に変化があった時のみ再レンダリングされます(selector
が利用している状態には依らない)。
const isOddCountState = selector<boolean>({
key: 'isOddCount',
get: ({ get }) => {
const count = get(countState);
return count % 2 === 1;
},
});
データの取り扱い
データを扱う基本的なhooksとしてはuseRecoilState
、useRecoilValue
、useSetRecoilState
があります。
useRecoilState
はReactのuseState
と同じような返り値を返します。引数に渡すのは状態の初期値ではなく状態であることに注意してください。
const [count, setCount] = useRecoilState(countState);
ReactではuseState
を用いて状態の定義とgetterとsetterの取得をまとめて行っていたのに対して、Recoilではatom
やselector
を用いて状態の定義を行い、useRecoilState
を用いてgetterとsetterを取得します。宣言と利用が分かれたのでコード全体の見通しが良くなりました。
useReacilValue
はgetterだけを取得します。サブスクライブだけしたい場合はこれを利用してください。
const count = useRecoilValue(countState);
useSetRecoilState
はsetterだけを取得します。状態をサブスクライブしないので、これを利用するコンポーネントでは指定した状態が更新されても再レンダリングは起きません。パフォーマンス上の利点が大きいので積極的に利用してください。
const setCount = useSetRecoilState(countState);
family
Recoilで扱える状態にはatom
とselector
を発展させたatomFamily
とselectorFamily
も存在します。基本的な性質はatom
やselector
と同じですが、familyはパラメータを持つことができます。atom
やselector
は一つの状態につき一つの値を持ちますが、familyはパラメータごとに値を持たせることができます。
基本の形とほとんど変わらずに定義することができます。
const countState = atomFamily<number, { id: number }>({
key: 'count',
default: ({ id }) => 0,
});
状態を扱うときもほとんど変わりません。
const [count, setCount] = useRecoilState(countState({ id: 0 }));
この例ではcountState
がid
ごとに異なる値を持ちます。id
が0
の状態を1
に更新したのちに、id
が1
の状態を確認してもデフォルト値の0
が得られます(0
の状態を確認するともちろん1
です)。データ一覧に対するデータの詳細を状態として定義したいときにidごとにデータを持つことができるように、状態の表現の幅が広がります。
useRecoilCallback
useRecoilCallback
は名前の通りReactのuseCallback
と似た機能を持つhooksです。
このhooksではこの関数内だけで状態を利用することが可能です。利用する状態はサブスクライブされますが、更新されてもコンポーネントの再レンダリングは起きません。
下のように引数にはcallback
関数とdeps
依存配列を取ります。
useRecoilCallback(callback, deps)
deps
の機能はuseCallback
と同じなので省略します。eslint
の設定を下記のように追加すると、依存配列を正しく設定できるのでお勧めです。
{
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": [
"warn", {
"additionalHooks": "useRecoilCallback"
}
]
}
}
callback
では最初に状態に対する操作のための関数を引数で選択します。次にこの関数を利用する側が渡す引数を設定します。その後、それらを使って行いたいことを記述します。操作のために提供される関数は6つあります。snapshot
はアクセスした時の状態のSnapshot
オブジェクトを取得することができます。gotoSnapshot
は状態をSnapshot
に同期させます。set
は状態を更新します。reset
は状態をデフォルトの状態にリセットします。refresh
はselector
のキャッシュをリセットします(セレクターは非同期データを扱うときに有効的です)。transact_UNSTABLE
はUNSTABLEなので省略します。Snapshot
オブジェクトは状態に対して行える様々な機能を持っていますが、useRecoilCallback
でよく使われるのはgetPromise
とgetLoadable
です。getPromise
は状態の値を非同期で取得することができます。非同期であることによって状態が扱うデータが同期か非同期かを気にする必要がなくなるというわけです。
const logCount = useRecoilCallback({ snapshot }) => async () => {
const count = await sanpshot.getPromise(countState);
console.log(`現在のカウント数:${count}`);
}, []);
この関数をボタンなどに仕込んであげると、countState
をサブスクライブせずにログを吐かせることが出来ます。
他にも色々な機能がありますが、おさらいはこの程度にしておきます。
非同期データを扱う
Recoilでは非同期データを状態として扱うことができます。
一番簡単な例としてタスクの一覧を取得できる非同期関数getTasks
を用いてatom
を作成します。
const tasksState = atom<Task[]>({
key: 'taskList',
default: getTasks(),
});
この例ではdefault
に非同期関数が渡ったこと以外は同期的に状態を扱う時と見た目はほとんど変わりません。これらのデータを扱うにはSuspenseを使うケースとそうでないケースに分かれます。
Suspense
Suspense
を利用する場合は同期的な関数とほとんど変わらない方法で利用することができます。
const TaskList = (): JSX.Element => {
const tasks = useRecoilValue(tasksState);
return (
<>
{tasks.map((task) => <Task key={task.id} task={task} />)}
</>
);
}
状態が扱うデータが同期、非同期を気にせずに利用できるのでとても利用しやすいです。
Suspense
を使う場合は当然ですが設置を忘れた時のためにルートにも設置してください。全ての祖先でSuspense
を設置していないコンポーネントで非同期データを扱う状態を利用するとエラーが出るので気をつけてください。RecoilRoot
の配下に設置しておくのが一番安全です。
return (
<RecoilRoot>
<Suspense fallbask={<Loading />}>
{children}
</Suspense>
</RecoilRoot>
);
Suspense
を利用するととても快適ですが、非同期関数でエラーを吐いた時の処理が大変です。一番簡単な方法はErrorBoundary
を利用する方法です。
return (
<RecoilRoot>
<Suspense fallbask={<Loading />}>
<ErrorBoundary>
{children}
</ErrorBoundary>
</Suspense>
</RecoilRoot>
);
この方法は同期的な状態、読み込み中の状態、エラーの状態の全てをきれいに管理することができるのでおすすめです。
ErrorBoundary
を使わない場合は状態として同期的な状態、エラーの状態の二つを定義して、コンポーネント側で出し分ける必要があります。
const tasksState = atom<Task[]>({
key: 'taskList',
default: () => {
const response = getTasks();
if (response.error) {
return {
state: 'hasError',
contents: response.error,
}
}
return {
state: 'hasValue',
contents: response.tasks,
}
}
});
宣言的という特徴が薄れるのであまりお勧めできませんが、ErrorBoundaryを使えない場合はこのようにするか次に紹介するSuspense
を使わない時の表現を利用すると手段も残されています。
No Suspense
React18以前で開発をしているなど様々な理由でSuspense
を利用できないケースがあるかと思います。Suspense
を用いない場合は状態を扱うhooksが同期的なものと異なります。useRecoilState
はuseRecoilStateLoadable
に、useRecoilValue
はuseRecoilValueLoadable
になります。useSetRecoilState
は状態を設置するだけなので変わりません。これらのhooksは値をLoadaale
というオブジェクトの形式で扱います。
const tasksLadable = useRecoilValueLoadable(tasksState);
Loadable
はstate
とcontents
の二つのプロパティを持ちます。state
はhasValue
、hasError
、Loading
など現在の状態の状況を持ちます。contents
はstate
によって異なる値を持ちます。hasValue
であれば取得したデータの状態、hasError
であればエラーオブジェクト、loading
であればPromise
を持ちます。Loadable
は様々な機能を持ちますが安定した機能ではないのでここでは紹介しないことにします。state
とcontents
があれば、基本的には困らないはずです。
Loadbale
はコンポーネントで以下のように利用します。
const TaskList = (): JSX.Element => {
const tasksLoadable = useRecoilValue(tasksState);
if (tasksLoadable.state === 'loading') {
return <Loading />
}
if (tasksLoadable.state === 'hasError') {
throw tasksLoadable.contents
}
return (
<>
{tasks.contents.map((task) => <Task key={task.id} task={task} />)}
</>
);
}
他にもuseEffect
を用いて非同期状態を取得したのちに状態をセットするような方法が考えられますが避けた方が良いと考えています。その状態を扱う各コンポーネントでuseEffect
の処理を書く必要があり可読性が悪くなることや、状態が持つ型が扱いづらいものになることなどのデメリットが考えられるからです。
状態の更新
非同期データを扱っているときはDataBaseの値など、外部ソースのデータを参照していることが多いです。Recoilでは状態をグローバルに持つのでデータの再取得はリロードを行う限りされません。これでは古いデータを持ち続けたままユーザーに利用させてしまいます。何らかのタイミングでデータの再取得を行いデータをある程度新しいものに更新したいです。Recoilの提供するhooksにuseResetRecoilState
があります。これは状態を初期化するhooksです。
const reset = useResetRecoilState(tasksState);
のように定義されます。これを使えば更新できそうですが、残念ながら状態の初期化はされますが新しいデータを受け取ることはできません。最初に行った関数の結果が返ってくるだけです。これはreset
を実行しても保存していたデフォルトの値に置き換えているだけなのが原因ではないかと考えています(ソース見ていないので推測です)。
これを解決する3つの策があります。一つ目はリクエスト回数を表す状態を作ることです。
const requestId = atom<number>({
key: 'requestId',
default: 0,
});
const tasks = selector<Task[]>({
key: 'tasks',
get: async ({ get }) => {
get(requestId);
const await response = getTasks();
return response.tasks;
}
});
selector
は依存した状態が変化するごとに計算し直すので、requestId
を変化させる(+1する)ごとにデータを新しく読み込んでくれます。しかし、この方法は原始的であまり行いたくありません。
二つ目はuseRecoilRefresher_UNSTABLE
を用いることです。selector
を構成する関数は純粋関数なので依存する状態が変わらなければその状態は変わらないはずです。その信念をもとにselector
の結果はキャッシュされています。つまりこのキャッシュを削除すれば再度関数を実行する必要が出てくるので新しいデータを得ることが可能になります。キャッシュの削除のための関数がuseRecoilRefresher_UNSTABLE
です。
const tasksState = selector<Task[]>({
key: 'taskList',
get: async () => {
const response = await getTasks();
return response.tasks;
}
});
のようにselector
を定義してデータの再読み込みをしたい箇所で
const refresh = useRecoilRefresher_UNSTABLE(tasksState);
のように宣言して得られるrefresh
を実行するとデータの読み込みが開始されます。他の状態に依存しないのにselector
を使う点や、このhooksがUNSTABLEなのが懸念点として挙げられます。
最後にatom
を更新する方法です。任意のタイミングでデータの読み込みを行なってその結果にatom
を更新する方法です。hooksを作成して提供することでシンプルに書くことが出来ます。
const useRefreshTasks = () => {
const refreshTasks = useRecoilCallback(({ set }) => async () => {
const response = await getTasks();
set(tasksState, response.tasks);
}, []);
return refreshTasks;
};
拡張性もあるので、useRecoilRefresher
がUNSTABLEのうちはこれがベストな方法ではないかと考えています。
データの事前読み込み
Recoilでは非同期データを扱う状態の事前読み込みを行うことができます。
const handleChangeTask = useRecoilCallback({ snapshot, set }) => (id: number) => {
snapshot.getLoadable(taskState(id))
set(taskId, id);
});
この例では、表示するタスクの変更と同時に変更後のタスクの読み込みを開始させています。
さいごに
Recoilで非同期データと扱う方法を紹介しました(見返すとおさらいが長いのでほんとに非同期の話しているか疑問に感じました)。非同期データの扱いやすさという面ではSWRなどのデータ取得ライブラリには及びませんが、グローバルな状態を一つのライブラリで管理できるという視点では悪くないのではないでしょうか。UNSTABLEな機能が安定するとより使いやすくなると思うので安定化が楽しみです。