30
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

概要

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

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

アプリの説明

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

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

image.png

image.png

コードの解説

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>
  );
}

キーワードの入力状態はkeywordInputStateuseRecoilStateで参照して制御している。もちろんこれくらいならべつに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に作っておくかする必要がありそう。

30
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?