非同期データクエリをRecoilのselectorで実現する

## 概要

非同期データクエリにRecoilのselectorを使ってみた場合にどのような動きをするかの実験。

なおコードはこちらで動作を確認できる
https://codesandbox.io/s/ecstatic-austin-k2c3z

## アプリの説明

いわゆるキーワード検索で名前をリストするだけのアプリ。データはネットワーク越しにキーワード検索することを想定（実際はローカルで絞り込んでwaitかけてるだけ）。

検索結果の非同期ロード中にはloading indicatorが検索結果にオーバレイするようになっている。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6495/962f5ba1-59d6-6570-9a2c-2c87f372d540.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6495/91807d14-c8f6-1a73-3fbb-aa3c7f9ef3f8.png)

## コードの解説

### atom/selectorの定義

```typescript
/**
 * 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となっている。

### コンポーネントの定義

#### キーワード入力と検索ハンドラ

```tsx
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`)にセットしているだけ。


#### 検索結果の表示

```tsx
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`というカスタムフックを利用している。

#### アプリ全体

```tsx
export default function App() {
  return (
    <div className="App">
      <KeywordSearchForm />
      <SearchResult />
    </div>
  );
}
```


### カスタムフック

Recoilの場合、非同期処理中はコンポーネントを`<Suspense>`で囲うことで代替コンテンツを表示可能だが、同じコンポーネントでの前回の結果をそのまま表示したい、などという場合にはむずかしい。さらにオフィシャルの`useRecoilStateLoadable`では、そのままでは非同期直前の解決済み値を取得できない。そのため最後に解決した値を取得できるように`Loadable`オブジェクトに`getLastResolvedValue()`なるメソッドを生やすカスタムフックを用意した。

```typescript
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に作っておくかする必要がありそう。