【TypeScript】【React】【Recoil】翻訳しながらそれなりに型をつけて細々と進めていくRecoil Tutorial その3 Asynchronous Data Queries編(1)

f:id:lamechang_dev:20220213113950p:plain

本記事は、Reactの提供元であるFacebook改めMetaが開発中の新しい状態管理ライブラリである Recoil の公式チュートリアルを翻訳しつつ、TypeScriptで型付けをしながら進めていく連載の第三弾です。細々とやっていきます🙄 今回は Asynchronous Data Queries について。

recoiljs.org

Asynchronous Data Queries

Recoil provides a way to map state and derived state to React components via a data-flow graph. What's really powerful is that the functions in the graph can also be asynchronous. This makes it easy to use asynchronous functions in synchronous React component render functions. Recoil allows you to seamlessly mix synchronous and asynchronous functions in your data-flow graph of selectors. Simply return a Promise to a value instead of the value itself from a selector get callback, the interface remains exactly the same. Because these are just selectors, other selectors can also depend on them to further transform the data.

Selectors can be used as one way to incorporate asynchronous data into the Recoil data-flow graph. Please keep in mind that selectors represent "idempotent" functions: For a given set of inputs they should always produce the same results (at least for the lifetime of the application). This is important as selector evaluations may be cached, restarted, or executed multiple times. Because of this, selectors are generally a good way to model read-only DB queries. For mutable data you can use a Query Refresh or to synchronize mutable state, persist state, or for other side-effects consider the experimental Atom Effects API.


Recoilは、データフローグラフを介して状態と派生状態をReactコンポーネントマッピングする方法を提供します。本当に強力なのは、グラフの関数も非同期にできることです。これにより、同期Reactコンポーネントレンダリング関数で非同期関数を簡単に使用できるようになります。 Recoilを使用すると、セレクターのデータフローグラフで同期関数と非同期関数をシームレスに組み合わせることができます。セレクターの getコールバックから値自体ではなく、Promiseを値に返すだけで、インターフェースはまったく同じままになります。これらは単なるセレクターであるため、他のセレクターもデータをさらに変換するためにそれらに依存することができます

セレクタは、非同期データをRecoilデータフローグラフに組み込む1つの方法として使用できます。セレクタは idempotent(べき等) 関数を表すことを心に留めておいてください。特定の入力のセットの場合、それらは常に同じ結果(少なくともアプリケーションの有効期間)を生成する必要があります。セレクタ評価がキャッシュ、再起動、または複数回実行される可能性があるため、これは重要です。このため、セレクタは一般的に読み取り専用DBクエリをモデル化するための良い方法です。可変データの場合、クエリの更新を使用するか、可変状態を同期させることができます。

Synchronous Example

For example, here is a simple synchronous atom and selector to get a user name:


たとえば、これはユーザー名を取得するための単純なAtomSelectorです。


これは、非同期を含まない非常に単純な例ですネ。サンプルコードでは非同期を挟まないローカルデータが存在することを意図して記述されていたので、簡単なオブジェクトだけ追加して動作を確認しました。

// サンプルコードから追加
const tableOfUsers = [
  {
    name: "Jane",
  },
  {
    name: "Mike",
  },
];

// 以下は変更なし
const currentUserIDState = atom({
  key: "CurrentUserID",
  default: 1,
});

export const currentUserNameState = selector({
  key: "CurrentUserName",
  get: ({ get }) => {
    return tableOfUsers[get(currentUserIDState)].name;
  },
});

const CurrentUserInfo: React.VFC = () => {
  const userName = useRecoilValue(currentUserNameState);
  return <div>{userName}</div>;
};

export default CurrentUserInfo;

Asynchronous Example

If the user names were stored in some database we need to query, all we need to do is return a Promise or use an async function. If any dependencies change, the selector will be re-evaluated and execute a new query. The results are cached, so the query will only execute once per unique input.


ユーザー名がQueryを実行する必要があるDBに保存されている場合は、やることはPromiseを返すか、非同期を使用することだけです。依存関係が変更された場合、セレクタは再評価され、新しいクエリを実行します。結果がキャッシュされるため、Queryは一意の入力ごとに1回だけ実行されます。


実際のドキュメントでは、Promiseを返す myDBQuery()について特に具体例などがあげられていないので、json-serverを使うなどして簡易的なエンドポイントを作ればいいかと思います。一応、json-serverを利用して書いた myDBQuery()側のコードについても載せておきます。

// 特に変更点なし

export const currentUserNameQuery = selector({
  key: "CurrentUserName",
  get: async ({ get }) => {
    const response = await myDBQuery({
      userID: get(currentUserIDState),
    });
    return response.name;
  },
});
// myDBQuery

import axios from "axios";

export const BASE_URL = "http://localhost:3001";

export const apiClient = axios.create({
  baseURL: BASE_URL,
  headers: {
    "Content-Type": "application/json",
  },
});

export type User = {
  id: number;
  name: string;
};

export const myDBQuery = async ({
  userID,
}: {
  userID: User["id"];
}): Promise<User> => {
  return apiClient
    .get<User>(`/users/${userID}`)
    .then((res) => res.data);
};
// json-serverで利用するJSON
// json-server --p 3001 --delay 1000 --watch path/to/xx.json
{
  "users": [
    {
      "id": 1,
      "name": "Jane"
    },
    {
      "id": 2,
      "name": "Mike"
    },
    {
      "id": 3,
      "name": "Cathy"
    }
  ]
}


The interface of the selector is the same, so the component using this selector doesn't need to care if it was backed with synchronous atom state, derived selector state, or asynchronous queries!


Selectorのインターフェースは同じであるため、このSelectorを使用したコンポーネントは、値が Atom か Selector、または非同期Queryで返されているかどうかなどを気にする必要がありません!


But, since React render functions are synchronous, what will it render before the promise resolves? Recoil is designed to work with React Suspense to handle pending data. Wrapping your component with a Suspense boundary will catch any descendants that are still pending and render a fallback UI:


しかし、ReactのRender関数は同期処理なので、Promiseが解決する前に何がレンダリングされるのでしょうか。Recoilは、Pendingデータを処理するために React Susenseで動作するように設計されています。Suspense boundaryコンポーネントをWrapすると、まだ保留中の子コンポーネントがキャッチされ、フォールバックUIをレンダリングします。

ここは実際にコードを書く際に、 <React.Suspense> をはずしてみて画面を読み込む際に何が起きるか、を見るのが一番わかりやすいと思います。以下のように「Suspense状態なのにfallback UIが指定されてないから<Suspense>コンポーネント使って指定してね」と怒られます。

f:id:lamechang_dev:20220211132126p:plain

<React.Suspense>コンポーネントを囲ってあげると、Promiseが解決されるのを待機している状態では以下のような挙動になります。

f:id:lamechang_dev:20220212130622g:plain


const App: React.FC = () => {
  return (
    <RecoilRoot>
       // React.Suspenseを外すとエラーになる
      <React.Suspense fallback={<div>Loading...</div>}>
        <CurrentUserInfo />
      </React.Suspense>
    </RecoilRoot>
  );
};
export default App;

Error Handling

But what if the request has an error? Recoil selectors can also throw errors which will then be thrown if a component tries to use that value. This can be caught with a React . For example:


しかし、リクエストにエラーがある場合はどうなりますか?RecoilのSelectorはまた、コンポーネントがその値を使用しようとしている場合にスローされるエラーをスローすることもできます。これはコンポーネント <ErrorBoundary>で捉えることができます。例えば

const currentUserNameQuery = selector({
  key: 'CurrentUserName',
  get: async ({get}) => {
    const response = await myDBQuery({
      userID: get(currentUserIDState),
    });
    if (response.error) {
      throw response.error;
    }
    return response.name;
  },
});

function CurrentUserInfo() {
  const userName = useRecoilValue(currentUserNameQuery);
  return <div>{userName}</div>;
}

function MyApp() {
  return (
    <RecoilRoot>
      <ErrorBoundary>
        <React.Suspense fallback={<div>Loading...</div>}>
          <CurrentUserInfo />
        </React.Suspense>
      </ErrorBoundary>
    </RecoilRoot>
  );
}

Recoil側のリファレンスでError Boundary についての詳細の説明がないのでこの記事においても詳細は省きますが、以下のような Error Boundary コンポーネントを用意して上記の例のようにWrapした上で、selectorの中でPromiseの解決に問題が発生した際などにErrorをthrowをすると、以下のような画面を表示することができます。

これは「クラッシュした UI を表示させる代わりに、フォールバック用の UI を表示するコンポーネントを準備して見せた方がユーザから見るとマシだよね」、といった感じですよね。

import React, { Component, ErrorInfo, ReactNode } from "react";

interface Props {
  children: ReactNode;
}

interface State {
  hasError: boolean;
}

class ErrorBoundary extends Component<Props, State> {
  public state: State = {
    hasError: false
  };

  public static getDerivedStateFromError(_: Error): State {
    // Update state so the next render will show the fallback UI.
    return { hasError: true };
  }

  public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error("Uncaught error:", error, errorInfo);
  }

  public render() {
    if (this.state.hasError) {
      return <h1>Sorry.. there was an error</h1>;
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

f:id:lamechang_dev:20220212124540p:plain

Queries with Parameters

Sometimes you want to be able to query based on parameters that aren't just based on derived state. For example, you may want to query based on the component props. You can do that using the selectorFamily helper:


派生状態に純粋に基づいていないパラメータに基づいてクエリしたい場面があるでしょう。たとえば、コンポーネントのPropsに基づいてQueryしたい時が出てくるでしょう。selectorFamilyヘルパーを使用してそれをすることができます。


このatomFamilyselectorFamily の概念は実際の現場ではかなり重宝する印象です。というのも

仮に「id=”1″ のUserの情報」を表示して「id=”2″のUserの情報」に切り替えたい時に、もしatomだけを愚直に使うなら、切り替える際にatomの状態をいったんクリアするような処理が必要となってしまいますが、そのようなことはする必要なくなりますね。

型を定義する上でのポイントとしては、selectorFamilyは型引数を2つ受け付けるようになっていて、1つ目については selector と完全に同じものですが、2つ目については get()で利用するパラメータに対して型を付けることができます。

export const userNameQuery = selectorFamily<string, number>({
  key: 'UserName',
  get: userID => async () => {
    const response = await myDBQuery({ userID });
    if ("isAxiosError" in response)
    {
      throw response.message
    }
    return response.name;
  },
});
const UserInfo: React.VFC<{ userID: number }> = ({ userID }) => {
  // 引数を受け付ける
  const userName = useRecoilValue(userNameQuery(userID));
  return <div>{userName}</div>;
}

export default UserInfo
const App: React.VFC = () => {
  return (
    <RecoilRoot>
      <ErrorBoundary>
        <React.Suspense fallback={<div>Loading...</div>}>
          <UserInfo userID={1}/>
          <UserInfo userID={2}/>
          <UserInfo userID={3} />
        </React.Suspense>
      </ErrorBoundary>
    </RecoilRoot>
  );
};
export default App;

Data-Flow Graph

Remember, by modeling queries as selectors, we can build a data-flow graph mixing state, derived state, and queries! This graph will automatically update and re-render React components as state is updated.


SelectorとしてQueryをモデル化することで、データフローグラフのミックスされた状態、派生状態、およびクエリを構築できます。このグラフは自動的にReact Componentを更新して更新して再レンダリングします。


The following example will render the current user's name and a list of their friends. If a friend's name is clicked on, they will become the current user and the name and list will be automatically updated.


次の例では、現在のユーザーの名前とその友達のリストを表示します。友達の名前をクリックすると、その友達が現在のユーザーになり、名前とリストが自動的に更新されます。

コードを読むと気づくことですが、各 UserfriendList を持つことを想定しているようなので、 型 Userを以下のように調整してあげましょう。

export type User = {
  id: number;
  name: string;
  // friendListを追加
  friendList: Array<User["id"]>
};

その上でチュートリアルのコードを型をつけて書いていくと以下のようになります。やはり、型引数のおかげで各 selector の挙動の見通しがかなり分かりやすいですね。

export const currentUserIDState = atom({
  key: "CurrentUserID",
  default: 1,
});

export const userInfoQuery = selectorFamily<User, number>({
  key: 'UserInfoQuery',
  get: userID => async () => {
    const response = await myDBQuery({userID});
    if ("isAxiosError" in response) {
      throw response.message;
    }
    return response;
  },
});

export const currentUserInfoQuery = selector<User>({
  key: 'CurrentUserInfoQuery',
  get: ({get}) => get(userInfoQuery(get(currentUserIDState))),
});

export const friendsInfoQuery = selector<Array<User>>({
  key: 'FriendsInfoQuery',
  get: ({get}) => {
    const {friendList} = get(currentUserInfoQuery);
    return friendList.map(friendID => get(userInfoQuery(friendID)));
  },
});
import { useRecoilValue, useSetRecoilState } from "recoil";
import { currentUserInfoQuery, friendsInfoQuery, currentUserIDState } from "../../Global/User";

const CurrentUserInfo: React.VFC = () => {
  const currentUser = useRecoilValue(currentUserInfoQuery);
  const friends = useRecoilValue(friendsInfoQuery);
  const setCurrentUserID = useSetRecoilState(currentUserIDState);
 return (
    <div>
      <h1>{currentUser.name}</h1>
      <ul>
        {friends.map(friend =>
          <li key={friend.id} onClick={() => setCurrentUserID(friend.id)}>
            {friend.name}
          </li>
        )}
      </ul>
    </div>
  );};

export default CurrentUserInfo;

記事が長くなりそうなので、Concurrent Requests 以降については別の記事として切り出そうと思います。Reactのdocは全般的に翻訳しようとするとルー大柴現象が起きますね。