概要
非同期データクエリにRecoilのselectorを使ってみた場合にどのような動きをするかの実験。
なおコードはこちらで動作を確認できる
https://codesandbox.io/s/ecstatic-austin-k2c3z
アプリの説明
いわゆるキーワード検索で名前をリストするだけのアプリ。データはネットワーク越しにキーワード検索することを想定(実際はローカルで絞り込んでwaitかけてるだけ)。
検索結果の非同期ロード中にはloading indicatorが検索結果にオーバレイするようになっている。
コードの解説
atom/selectorの定義
/**
* states
*/
const keywordInputState = atom({
key: "keywordInput",
default: ""
});
const keywordState = atom({
key: "keyword",
default: ""
});
const searchResultState = selector({
key: "searchResult",
async get({ get }) {
const keyword = get(keywordState);
const results = await searchEntries(keyword);
return results;
}
});
キーワードの入力を表すatom (keywordInputState
)と確定したキーワードを表すatom(keywordState
)、およびキーワード検索結果を表すselector(searchResultState
)を用意。
searchResultState
は確定したキーワードを利用して非同期コールであるsearchEntries()
を呼び出し、その結果を返すようなselectorとなっている。
コンポーネントの定義
キーワード入力と検索ハンドラ
function KeywordSearchForm() {
const [keywordInput, setKeywordInput] = useRecoilState(keywordInputState);
const onSearch = useRecoilCallback(async ({ getPromise, set }) => {
const keyword = await getPromise(keywordInputState);
set(keywordState, keyword);
}, []);
return (
<div className="search-form">
<input
type="text"
value={keywordInput}
onChange={e => setKeywordInput(e.target.value)}
onKeyDown={e => (e.keyCode === 13 ? onSearch() : null)}
/>
<button onClick={onSearch}>Search</button>
</div>
);
}
キーワードの入力状態はkeywordInputState
をuseRecoilState
で参照して制御している。もちろんこれくらいならべつにuseState
でもいいし、なんならuncontrolledでもいいのだけど、とりあえず。リターンキーが押下されたとき、Searchボタンが押されたときにonSearch()
ハンドラが発動するようになっている。
onSearch()
ではkeywordInputState
の値を読み取ってそれを確定キーワード(keywordState
)にセットしているだけ。
検索結果の表示
function SearchResult() {
const searchResultLoadable = useRecoilValueReplayLoadable(searchResultState);
const searchResult = searchResultLoadable.getLastResolvedValue();
return (
<div className="result">
{searchResult ? (
<ul className="list">
{searchResult.map(item => (
<li>{item.name}</li>
))}
</ul>
) : (
undefined
)}
{searchResultLoadable.state === "loading" ? (
<div className="loading">
<span>loading...</span>
</div>
) : (
undefined
)}
{searchResultLoadable.state === "hasError" ? (
<div className="error">{searchResultLoadable.contents.message}</div>
) : (
undefined
)}
</div>
);
}
検索結果はsearchResultState
を参照するhooksを利用する。非同期のローディング状態を判別するために、Loadable
オブジェクトとして取得している。もちろんuseRecoilValue
で直接値を取得してもいいけど、その場合<Suspense>
で囲う必要がある。
なお直接useRecoilValueLoadable
を使うのではなく、後述するようにuseRecoilValueReplayLoadable
というカスタムフックを利用している。
アプリ全体
export default function App() {
return (
<div className="App">
<KeywordSearchForm />
<SearchResult />
</div>
);
}
カスタムフック
Recoilの場合、非同期処理中はコンポーネントを<Suspense>
で囲うことで代替コンテンツを表示可能だが、同じコンポーネントでの前回の結果をそのまま表示したい、などという場合にはむずかしい。さらにオフィシャルのuseRecoilStateLoadable
では、そのままでは非同期直前の解決済み値を取得できない。そのため最後に解決した値を取得できるようにLoadable
オブジェクトにgetLastResolvedValue()
なるメソッドを生やすカスタムフックを用意した。
import { useRef, useEffect } from "react";
import { useRecoilValueLoadable, Loadable, RecoilValue } from "recoil";
type ReplayLoadable<T> = Loadable<T> & {
getLastResolvedValue(): T | undefined;
};
export function useRecoilValueReplayLoadable<T>(
state: RecoilValue<T>
): ReplayLoadable<T> {
const loadable = useRecoilValueLoadable(state);
const lastValueRef = useRef<T>();
useEffect(() => {
if (loadable.state === "hasValue") {
lastValueRef.current = loadable.contents;
}
}, [loadable]);
return {
...loadable,
getLastResolvedValue() {
return this.state === "hasValue" ? this.contents : lastValueRef.current;
}
};
}
ここらへん、Concurrent ModeでuseTransition
とかつかえばうまくできるのかもしれない。
実験
キャッシュ(memoize)について
上記のアプリのキーワードを変えて検索してみる。
searchEntries()
には強制的に1sec程度のwaitがかかっているが、違うキーワードで検索したあとに一度利用した同じキーワードで検索したときには、即座に検索結果が返ってくる。
このことから、非同期selectorであっても計算結果はキャッシュされている ことがわかる。また 依存するstateの直前の値だけでなく、変化したすべて(?)のstate値についての結果がmemoされている こともわかる。
非同期エラーについて
実際にはsearchEntries()
は、1/10の確率でエラーを返すようになっている(ネットワークエラーなどを想定)。これはランダムなので、同じキーワードで検索かけたらエラーにはならない可能性もあるだろうけど、エラーとなった同じキーワードで再度検索実行すると必ず無条件に即時エラーとなる。このことから 非同期処理中に発生したエラーについてもキャッシュされてしまう ということがわかる。
今の時点では、query version のようなatomを用意して、強制的にキャッシュの無効化(実際にはmemoization keyを変えてるだけだが)できるような依存をsearchResultState
selectorに作っておくかする必要がありそう。