lamechang

Webエンジニア lamechangのブログ。

【React】RecoilのSelectorで非同期を用いたデータ取得をする

Recoilについて

Context APIが抱えるいくつかの制約を解消しうる、Facebookによって提唱されたライブラリ。「Atom」「Selector」と呼ばれる単位を使用してアプリケーションデータを管理する。

Atom

Atomsは、ReduxでいうところのStoreに相当。 明確な違いとしては、Reduxはアプリケーション単位での状態管理であるのに対し、Atomsは一つひとつが状態を保持しているという点。 下記具体例。

const fontSizeState = atom<number>({
  key: 'fontSizeState',
  default: 14,
});

Selector

Selectorsは複数のAtom・他のSelectorを受け取る純粋な関数として定義する。 これらの上流のAtomまたはSelectorが更新されると、Selector関数が再評価される。

const fontSizeLabelState = selector<string>({
  key: 'fontSizeLabelState',
  get: ({get}) => {
    const fontSize = get(fontSizeState);
    const unit = 'px';

    return `${fontSize}${unit}`;
  },
});

非同期なデータクエリ方法について

Recoil を使用する際どこに非同期処理を書いていくことになるのかについてですが、Selectorの中に同期関数・非同期関数を混在させることができるのでそちらに定義することになる。 Selectorのgetキーに対して定義するコールバックにおいて、非同期処理を含む場合はコールバックの頭にasyncをつけることで実現可能。 以下は、キャラクターIDをAtomとして保持し、IDに応じたキャラクターネームをSelectorにおいて非同期リクエストによって取得するような実装になる。

export const characterIdState = atom<number>({
  key: "characterId",
  default: 1,
});

export const characterState = selector<string>({
  key: "character",
  get: async ({ get }) => {
    const characterId = get(characterIdState);
    try {
      const response: AxiosResponse<any> = await axios.get(`https://rickandmortyapi.com/api/character/${characterId}`);
      return response.data.name;
    }
    catch(err) {
      throw new Error('error happened on API request' + err);
    }
  },
});

上記の様なAtom、Selectorの定義を元に、セレクトボックスにて選択されたIDを基にキャラクターネームを取得するような実装は以下のような感じになる。 注意点としては、Reactのレンダリングはもちろん同期的に行われるので、Selectorで定義したAxiosリクエストのPromiseが解決される前の状態を考慮したUI設計にする必要あり。そのために、対象部分を<Suspense> でラップすると、まだPromiseが解決されていない子孫をキャッチして、予め用意しておいたフォールバックUIをレンダリングすることができる。

なお、<Suspense>のラップを漏らすと Error: CharacterInfo suspended while rendering, but no fallback UI was specified. とエラーがthrowされるのでいずれにせよ問題があることには気付ける。

import { Suspense } from 'react';
import axios, { AxiosResponse } from 'axios';
import { RecoilRoot, atom, selector, useRecoilValue, SetterOrUpdater, useSetRecoilState } from "recoil";

export const characterIdState = atom<number>({
  key: "characterId",
  default: 1,
});

export const characterNameState = selector<string>({
  key: "characterName",
  get: async ({ get }) => {
    const characterId = get(characterIdState);
    try {
      const response: AxiosResponse<any> = await axios.get(`https://rickandmortyapi.com/api/character/${characterId}`);
      return response.data.name;
    }
    catch(err) {
      throw new Error('error happened on API request' + err);
    }
  },
});

const CharacterInfo: React.FC = () => {
  const characterId: number = useRecoilValue(characterIdState);
  const characterName: string = useRecoilValue(characterNameState);
  const setCharacterId: SetterOrUpdater<number> = useSetRecoilState(characterIdState);

  const handleChange: React.ChangeEventHandler<HTMLSelectElement> = (e) => {
    setCharacterId(parseInt(e.target.value));
  }

  return (
    <>
      <select onChange={handleChange}>
        {/* IDリストについても非同期で取得するべきだが、簡略化 */}
        {[...Array(60)].map((v, i) => <option selected={characterId === i + 1} value={i + 1}>{i + 1}</option>)}
      </select>
      <p>{characterName}</p>
    </>
  );
}

const App: React.FC = () => {
  return (
    <RecoilRoot>
      <p>choose character ID</p>
      <Suspense fallback={<div>Loading...</div>}>
        <CharacterInfo />
      </Suspense>
    </RecoilRoot>
  );
}

export default App;