21
10

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 1 year has passed since last update.

1人フロントエンドAdvent Calendar 2022

Day 17

Recoilにおける非同期データの扱い方

Posted at

はじめに

Reactの状態管理ライブラリとしてMetaが開発しているRecoilがあります。Recoilは宣言的という点に重きにおいて開発している点や、Reactが提供するhooks APIのような書き心地ですので普段Reactを利用するユーザーにとってはとても親しみやすく感じるライブラリです。
この記事ではそんなRecoilで非同期データを扱う方法を紹介します。Recoil自体については以前記事を書いたのでこちらも合わせてご覧ください。

基本のおさらい

Recoilはatomselectorの二つの概念によって状態を構築します。

atom

atomはコンポーネントがサブスクライブする単位です。これ以上分離できない状態ということなので、命名にふさわしい性質を持っていますね(原子は陽子、中性子、電子からなっていてそれらもクォークなどの細かい粒子で構成されるというのは置いておいてある程度巨視的な視点においてです)。

const countState = atom<number>({
  key: 'count',
  default: 0,
});

selector

selectoratomもしくは別のselectorを利用して作られる状態です。構成する関数は純粋関数である必要があり、利用している状態が更新されるとselectorも更新されます。selectorをサブスクライブしたコンポーネントはそのselector自身に変化があった時のみ再レンダリングされます(selectorが利用している状態には依らない)。

const isOddCountState = selector<boolean>({
  key: 'isOddCount',
  get: ({ get }) => {
    const count = get(countState);
    return count % 2 === 1;
  },
});

データの取り扱い

データを扱う基本的なhooksとしてはuseRecoilStateuseRecoilValueuseSetRecoilStateがあります。
useRecoilStateはReactのuseStateと同じような返り値を返します。引数に渡すのは状態の初期値ではなく状態であることに注意してください。

const [count, setCount] = useRecoilState(countState);

ReactではuseStateを用いて状態の定義とgetterとsetterの取得をまとめて行っていたのに対して、Recoilではatomselectorを用いて状態の定義を行い、useRecoilStateを用いてgetterとsetterを取得します。宣言と利用が分かれたのでコード全体の見通しが良くなりました。
useReacilValueはgetterだけを取得します。サブスクライブだけしたい場合はこれを利用してください。

const count = useRecoilValue(countState);

useSetRecoilStateはsetterだけを取得します。状態をサブスクライブしないので、これを利用するコンポーネントでは指定した状態が更新されても再レンダリングは起きません。パフォーマンス上の利点が大きいので積極的に利用してください。

const setCount = useSetRecoilState(countState);

family

Recoilで扱える状態にはatomselectorを発展させたatomFamilyselectorFamilyも存在します。基本的な性質はatomselectorと同じですが、familyはパラメータを持つことができます。atomselectorは一つの状態につき一つの値を持ちますが、familyはパラメータごとに値を持たせることができます。
基本の形とほとんど変わらずに定義することができます。

const countState = atomFamily<number, { id: number }>({
  key: 'count',
  default: ({ id }) => 0,
});

状態を扱うときもほとんど変わりません。

const [count,  setCount] = useRecoilState(countState({ id: 0 }));

この例ではcountStateidごとに異なる値を持ちます。id0の状態を1に更新したのちに、id1の状態を確認してもデフォルト値の0が得られます(0の状態を確認するともちろん1です)。データ一覧に対するデータの詳細を状態として定義したいときにidごとにデータを持つことができるように、状態の表現の幅が広がります。

useRecoilCallback

useRecoilCallbackは名前の通りReactのuseCallbackと似た機能を持つhooksです。
このhooksではこの関数内だけで状態を利用することが可能です。利用する状態はサブスクライブされますが、更新されてもコンポーネントの再レンダリングは起きません。
下のように引数にはcallback関数とdeps依存配列を取ります。

useRecoilCallback(callback, deps)

depsの機能はuseCallbackと同じなので省略します。eslintの設定を下記のように追加すると、依存配列を正しく設定できるのでお勧めです。

{
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": [
      "warn", {
        "additionalHooks": "useRecoilCallback"
      }
    ]
  }
}

callbackでは最初に状態に対する操作のための関数を引数で選択します。次にこの関数を利用する側が渡す引数を設定します。その後、それらを使って行いたいことを記述します。操作のために提供される関数は6つあります。snapshotはアクセスした時の状態のSnapshotオブジェクトを取得することができます。gotoSnapshotは状態をSnapshotに同期させます。setは状態を更新します。resetは状態をデフォルトの状態にリセットします。refreshselectorのキャッシュをリセットします(セレクターは非同期データを扱うときに有効的です)。transact_UNSTABLEはUNSTABLEなので省略します。Snapshotオブジェクトは状態に対して行える様々な機能を持っていますが、useRecoilCallbackでよく使われるのはgetPromisegetLoadableです。getPromiseは状態の値を非同期で取得することができます。非同期であることによって状態が扱うデータが同期か非同期かを気にする必要がなくなるというわけです。

const logCount = useRecoilCallback({ snapshot }) => async () => {
  const count = await sanpshot.getPromise(countState);
  console.log(`現在のカウント数:${count}`);
}, []);

この関数をボタンなどに仕込んであげると、countStateをサブスクライブせずにログを吐かせることが出来ます。

他にも色々な機能がありますが、おさらいはこの程度にしておきます。

非同期データを扱う

Recoilでは非同期データを状態として扱うことができます。
一番簡単な例としてタスクの一覧を取得できる非同期関数getTasksを用いてatomを作成します。

const tasksState = atom<Task[]>({
  key: 'taskList',
  default: getTasks(),
});

この例ではdefaultに非同期関数が渡ったこと以外は同期的に状態を扱う時と見た目はほとんど変わりません。これらのデータを扱うにはSuspenseを使うケースとそうでないケースに分かれます。

Suspense

Suspenseを利用する場合は同期的な関数とほとんど変わらない方法で利用することができます。

const TaskList = (): JSX.Element => {
  const tasks = useRecoilValue(tasksState);

  return (
    <>
      {tasks.map((task) => <Task key={task.id} task={task} />)}
    </>
  );
}

状態が扱うデータが同期、非同期を気にせずに利用できるのでとても利用しやすいです。
Suspenseを使う場合は当然ですが設置を忘れた時のためにルートにも設置してください。全ての祖先でSuspenseを設置していないコンポーネントで非同期データを扱う状態を利用するとエラーが出るので気をつけてください。RecoilRootの配下に設置しておくのが一番安全です。

return (
  <RecoilRoot>
    <Suspense fallbask={<Loading />}>
      {children}
    </Suspense>
  </RecoilRoot>
);

Suspenseを利用するととても快適ですが、非同期関数でエラーを吐いた時の処理が大変です。一番簡単な方法はErrorBoundaryを利用する方法です。

return (
  <RecoilRoot>
    <Suspense fallbask={<Loading />}>
      <ErrorBoundary>
        {children}
      </ErrorBoundary>
    </Suspense>
  </RecoilRoot>
);

この方法は同期的な状態、読み込み中の状態、エラーの状態の全てをきれいに管理することができるのでおすすめです。
ErrorBoundaryを使わない場合は状態として同期的な状態、エラーの状態の二つを定義して、コンポーネント側で出し分ける必要があります。

const tasksState = atom<Task[]>({
  key: 'taskList',
  default: () => {
    const response = getTasks();
    if (response.error) {
      return {
        state: 'hasError',
        contents: response.error,
      }
    }
    return {
      state: 'hasValue',
      contents: response.tasks,
    }
  }
});

宣言的という特徴が薄れるのであまりお勧めできませんが、ErrorBoundaryを使えない場合はこのようにするか次に紹介するSuspenseを使わない時の表現を利用すると手段も残されています。

No Suspense

React18以前で開発をしているなど様々な理由でSuspenseを利用できないケースがあるかと思います。Suspenseを用いない場合は状態を扱うhooksが同期的なものと異なります。useRecoilStateuseRecoilStateLoadableに、useRecoilValueuseRecoilValueLoadableになります。useSetRecoilStateは状態を設置するだけなので変わりません。これらのhooksは値をLoadaaleというオブジェクトの形式で扱います。

const tasksLadable = useRecoilValueLoadable(tasksState);

Loadablestatecontentsの二つのプロパティを持ちます。statehasValuehasErrorLoadingなど現在の状態の状況を持ちます。contentsstateによって異なる値を持ちます。hasValueであれば取得したデータの状態、hasErrorであればエラーオブジェクト、loadingであればPromiseを持ちます。Loadableは様々な機能を持ちますが安定した機能ではないのでここでは紹介しないことにします。statecontentsがあれば、基本的には困らないはずです。
Loadbaleはコンポーネントで以下のように利用します。

const TaskList = (): JSX.Element => {
  const tasksLoadable = useRecoilValue(tasksState);
  if (tasksLoadable.state === 'loading') {
    return <Loading />
  }
  if (tasksLoadable.state === 'hasError') {
    throw tasksLoadable.contents
  }

  return (
    <>
      {tasks.contents.map((task) => <Task key={task.id} task={task} />)}
    </>
  );
}

他にもuseEffectを用いて非同期状態を取得したのちに状態をセットするような方法が考えられますが避けた方が良いと考えています。その状態を扱う各コンポーネントでuseEffectの処理を書く必要があり可読性が悪くなることや、状態が持つ型が扱いづらいものになることなどのデメリットが考えられるからです。

状態の更新

非同期データを扱っているときはDataBaseの値など、外部ソースのデータを参照していることが多いです。Recoilでは状態をグローバルに持つのでデータの再取得はリロードを行う限りされません。これでは古いデータを持ち続けたままユーザーに利用させてしまいます。何らかのタイミングでデータの再取得を行いデータをある程度新しいものに更新したいです。Recoilの提供するhooksにuseResetRecoilStateがあります。これは状態を初期化するhooksです。

const reset  = useResetRecoilState(tasksState);

のように定義されます。これを使えば更新できそうですが、残念ながら状態の初期化はされますが新しいデータを受け取ることはできません。最初に行った関数の結果が返ってくるだけです。これはresetを実行しても保存していたデフォルトの値に置き換えているだけなのが原因ではないかと考えています(ソース見ていないので推測です)。
これを解決する3つの策があります。一つ目はリクエスト回数を表す状態を作ることです。

const requestId = atom<number>({
  key: 'requestId',
  default: 0,
});
const tasks = selector<Task[]>({
  key: 'tasks',
  get: async ({ get }) => {
    get(requestId);
    const await response = getTasks();
    return response.tasks;
  }
});

selectorは依存した状態が変化するごとに計算し直すので、requestIdを変化させる(+1する)ごとにデータを新しく読み込んでくれます。しかし、この方法は原始的であまり行いたくありません。
二つ目はuseRecoilRefresher_UNSTABLEを用いることです。selectorを構成する関数は純粋関数なので依存する状態が変わらなければその状態は変わらないはずです。その信念をもとにselectorの結果はキャッシュされています。つまりこのキャッシュを削除すれば再度関数を実行する必要が出てくるので新しいデータを得ることが可能になります。キャッシュの削除のための関数がuseRecoilRefresher_UNSTABLEです。

const tasksState = selector<Task[]>({
  key: 'taskList',
  get: async () => {
    const response = await getTasks();
    return response.tasks;
  }
});

のようにselectorを定義してデータの再読み込みをしたい箇所で

const refresh = useRecoilRefresher_UNSTABLE(tasksState);

のように宣言して得られるrefreshを実行するとデータの読み込みが開始されます。他の状態に依存しないのにselectorを使う点や、このhooksがUNSTABLEなのが懸念点として挙げられます。
最後にatomを更新する方法です。任意のタイミングでデータの読み込みを行なってその結果にatomを更新する方法です。hooksを作成して提供することでシンプルに書くことが出来ます。

const useRefreshTasks = () => {
  const refreshTasks = useRecoilCallback(({ set }) => async () => {
    const response = await getTasks();
    set(tasksState, response.tasks);
  }, []);

  return refreshTasks;
};

拡張性もあるので、useRecoilRefresherがUNSTABLEのうちはこれがベストな方法ではないかと考えています。

データの事前読み込み

Recoilでは非同期データを扱う状態の事前読み込みを行うことができます。

const handleChangeTask = useRecoilCallback({ snapshot, set }) => (id: number) => {
  snapshot.getLoadable(taskState(id))
  set(taskId, id);
});

この例では、表示するタスクの変更と同時に変更後のタスクの読み込みを開始させています。

さいごに

Recoilで非同期データと扱う方法を紹介しました(見返すとおさらいが長いのでほんとに非同期の話しているか疑問に感じました)。非同期データの扱いやすさという面ではSWRなどのデータ取得ライブラリには及びませんが、グローバルな状態を一つのライブラリで管理できるという視点では悪くないのではないでしょうか。UNSTABLEな機能が安定するとより使いやすくなると思うので安定化が楽しみです。

21
10
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
21
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?