本記事は、Reactの提供元であるFacebook改めMetaが開発中の新しい状態管理ライブラリである Recoil の公式チュートリアルを翻訳しつつ、TypeScriptで型付けをしながら進めていく連載の第三弾です。細々とやっていきます🙄 今回は Asynchronous Data Queries
について。
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:
たとえば、これはユーザー名を取得するための単純な
Atom
とSelector
です。
これは、非同期を含まない非常に単純な例ですネ。サンプルコードでは非同期を挟まないローカルデータが存在することを意図して記述されていたので、簡単なオブジェクトだけ追加して動作を確認しました。
// サンプルコードから追加 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>
コンポーネント使って指定してね」と怒られます。
<React.Suspense>
でコンポーネントを囲ってあげると、Promiseが解決されるのを待機している状態では以下のような挙動になります。
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;
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
ヘルパーを使用してそれをすることができます。
このatomFamily
とselectorFamily
の概念は実際の現場ではかなり重宝する印象です。というのも
仮に「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.
次の例では、現在のユーザーの名前とその友達のリストを表示します。友達の名前をクリックすると、その友達が現在のユーザーになり、名前とリストが自動的に更新されます。
コードを読むと気づくことですが、各 User
が friendList
を持つことを想定しているようなので、 型 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は全般的に翻訳しようとするとルー大柴現象が起きますね。