Jotaiでのデータ取得の基本 - SuspenseとAsync Atom

2022/09/02に公開

はじめに

本記事では、Reactの状態管理ライブラリであるJotaiにおける、リクエスト処理など非同期処理の基本的な方法を紹介します。

概要

  • Read-Only Atom の復習 <- Jotai初学者向け
  • 非同期処理を持つ Read-Only Atom の扱い方の紹介 <- Suspense と Async atomの基本
    • 非同期で配列表示する例
    • 一覧画面の例 <- ページネーション無し
    • 注意点
  • Dependency Atom と Async Atom、 jotai/utis の紹介 <- 実践向きな使い方の紹介
    • Dependency Atom って何? <- Jotai初学者向け
    • ページネーション <- Dependency Atomを用いた例
    • キャッシュありのページネーション <- atomFamilyを用いた例
    • リフレッシュ方法 <- ちょっと・・・
    • 今のオススメ  <- @tanstack/react-queryインテグレーション!
  • Suspenseを使わない場合 <- loadable apiの紹介
  • Async atomの注意と改善方法 <- Promiseの解決順について
  • Atomのwrite関数をasyncで書く
  • おわりに

Read-Only Atom の復習

atomにread関数を与えて定義すると read-only atom となります。(read関数はgetを引数に持ちますがここでは使わないので省略)
Read-only atom は"更新できません"。なのでlistAtomは単に['item a', 'item b', 'item c']を返すだけのatomです。

atoms.ts
import { atom } from 'jotai';

export const listAtom = atom(() => ['item a', 'item b', 'item c']);

listAtomをコンポーネントで参照する場合、以下のように書けます。
const list = useAtomValue(listAtom)は、const [list] = useAtom(listAtom) とも書けます。

List.tsx
import { useAtomValue } from 'jotai';
import { listAtom } form './atoms'; 

const List = () => {
  const list = useAtomValue(listAtom);
  return (
    <ul>
      {list.map((item) => <li>{item}</li>)}
    </ul>
  );
};

export default List;

非同期処理を持つ Read-Only Atom の扱い方の紹介

非同期で配列表示する例

上記の配列を非同期的に表示するためには、listAtomをasync atomにします。
read関数をasync関数として定義し、atomを使うコンポーネントをSuspenseで囲みます。単にasync関数にするだけでは違いがわからないのでsleepを用意してみます。

atoms.ts
import { atom } from 'jotai';

export const listAtom = atom(async () => {
  await sleep(3000);
  return ['item a', 'item b', 'item c'];
});
List.tsx
import { useAtomValue } from 'jotai';
import { listAtom } form './atoms'; 

const ListMain = () => {
  const list = useAtomValue(listAtom);
  return (
    <ul>
      {list.map((item) => <li>{item}</li>)}
    </ul>
  );
};

const List = () => (
  <Suspense fallback="loading...">
    <ListMain />
  </Suspense>
);

export default List;

実際に<List />を描画したものがコチラです。listAtomのPromiseが解決されるまではfallbackが表示され、解決されると<ListMain />が表示されます。

一覧画面の例(ページネーション無し)

上記と大差ありませんが、実際のapiからfetchする例を用意しました。

https://github.com/tell-y/jotai-training/tree/main/jotai-async-atom-example-00

注意点

(当たり前のことなのですが)注意すべき点として、一度async atomをマウントするとアンマウントされるまでは結果が維持され続けます。

const A = () => {
   useAtomValue(asyncAtom); // async atomをマウント
   ...
};

const B = () => {
   useAtomValue(asyncAtom); // async atomをマウント
   ...
};

const SomewhereX = () => (
  <Suspense fallback="loading...">
    <A />
  </Suspense>
);

const SomewhereY = () => (
  <Suspense fallback="loading...">
    <B />
  </Suspense>
);

上記のようにしてasyncAtomが使われているとします。
最初に<SomewhereX />がマウントされたとすると次に<SomewhereX />(or <SomewhereY />)をレンダリングする際はPromiseは解決されているのでfallbackは出ません。
(当初慣れていない時はこの仕様を忘れてしまい意図しない挙動になってしまった経験があるので自戒も込めて。)

ちなみに、最新の内容をリクエストしたければ、(現実的ではないですが)再マウントするかdependency atomを使ってリフレッシュする必要があります。dependency atomの利用については後述します。

Dependency Atom と Async Atom、 jotai/utis の紹介

上記で紹介した例はあまり実戦向きとは言えない内容ではありました。
一般的には一覧表示といえばページネーション有りきになると思うのでここではページネーションの実現方法を紹介します。

その前に Dependency Atom の復習をしたいと思います。

Dependency Atom って何?

atomからatomを参照することができます。以下の場合、textLenAtomやuppercaseAtomはtextAtomに依存しています。これをDependency Atomと呼びます。

Text Length exampleより引用)

ページネーション(Dependency Atomを用いた例)

Async atomであっても例外なくDependency Atomを持つことができます。
ここまで来るともはや述べる必要はなさそうですが、ページネーションを実現する為にはページ情報を表現するatomをAsync atomに持たせればよいことになります。
上記の例を拡張してページネーションを追加したものが以下になります。

atom.ts
export const peopleUrlAtom = atom<string>("https://swapi.dev/api/people");

// peopleUrlAtomが変化する度にpeopleAtomが反応
export const peopleAtom = atom(async (get) => {
  const url = get(peopleUrlAtom);
  const {
    results: people,
    next: nextUrl,
    previous: previousUrl
  } = await fetchPeople(url);
  return { people, nextUrl, previousUrl };
});

一覧表示したい内容を取得するAPIのURLをpeopleUrlAtomで表します。
Prev, Next ボタンを用意し、それぞれのonClickで次(前)ページのURLがpeopleUrlAtomにsetされるようにします。
Async atomのpeopleAtomはpeopleUrlAtomに依存しているので、peopleUrlAtomが変化するたびに反応します。(peopleUrlを元にfetchが走りPromiseが解決されるまでloading...になり、解決後に結果が表示)

https://github.com/tell-y/jotai-training/tree/main/jotai-async-atom-example-01

キャッシュありのページネーション(atomFamilyを用いた例)

上記を触ってみて頂くとお分かりかと思いますが、一度表示したURLであっても毎回ネットワークリクエストが走ります。 これでよい事は多いでしょうが、リクエストはある程度抑えたい時もあるかと思います。その場合はキャッシュを持たせる必要があります。
ここでは比較的簡易にキャッシュを実現する方法をjotai/utilsから提供されているatomFamilyを例に紹介します。

動作は今述べたように、一度表示したURLはキャッシュし、2回目以降はキャッシュが表示されるようになります。

atomFamilyはatomを返す関数を作ります。(= peopleFamily)
受け取るパラメータをキーにしてatomを作ります。もし、与えられたキーが以前受け取っていれば(atomが有れば)atomを作らず持っているatomを返します。今回の場合はURLの文字列がキーになります。

atom.ts
...

// urlをキーとしてasync atomを作成
export const peopleFamily = atomFamily((url: string) =>
  atom(async () => {
    const {
      results: people,
      next: nextUrl,
      previous: previousUrl
    } = await fetchPeople(url);
    return { people, nextUrl, previousUrl };
  })
);
App.tsx
const People = () => {
  const [peopleUrl, setPeopleUrl] = useAtom(peopleUrlAtom);
  const { people, nextUrl, previousUrl } = useAtomValue(
    peopleFamily(peopleUrl) // 新しいurlであれば新規にatomを、既に有ればそのatomを返す
  );
  ...
};

これはatomFamilyを使う場合の注意点ですが、キーが無限に増え続けるようなものを扱う場合はメモリーリークが発生するため気をつけてください。このことについての詳細はドキュメントを参照ください。

https://github.com/tell-y/jotai-training/tree/main/jotai-async-atom-example-01-atomFamily

リフレッシュ方法

「ページネーション(Dependency Atomを用いた例)」にて、read関数内で依存する他のatom(Dependency Atom)が更新されることでatomが再評価(read関数が再実行)されることは述べました。
この依存atom(他のatom)がきちんと使われるならいいのですが、使わないこともコードとしては書けてしまいます。これはJotaiではご法度になります。
このように書いてしまうことを許容してしまうと、xAtomの値が変わる度に無意味にdoubleAtomが反応するからです。

const doubleAtom = atom((get) => {
  get(xAtom); // 使わないのはNG
  return val * get(numAtom); // 使うならOK
});

ただ、Async Atomのリフレッシュをさせる為にはすごく魅力的で便利な仕様になります。
以下のようなversionAtomをリフレッシュしたい時に+1してあげるだけで簡単にリフェッチが実現できます。

export const versionAtom = atom(0);
export const dataAtom = atom(async (get) => {
  get(version);
  const response = await fetch(...);
  return response.json();
});

(作者のコメントより引用)

ドキュメントでも紹介されている方法です。(atomWithRefresh)
ドキュメントの方法だと、上記で言うところのversionAtomが隠蔽されているので問題はないでしょう。

今のオススメ(with @tanstack/react-query)

ネットワーク処理をjotaiのコア機能のみで扱う場合、やりたいことが簡単にはできないことが出てきます。(出来ないことはないと思いますが自前で実装する必要が多いです)
このような場合に非常に役立つのが、@tanstack/react-queryとのインテグレーション機能(jotai/query)です。(以降、React QueryをRQと表記)
RQは言わずと知れたasynchronous state managementライブラリですが(余談:SWRdata fetchingライブラリ)、 RQから提供される手厚い機能をjotaiのatomとして扱うことが出来ます(※ 2022年8月の時点ではqueryのみサポート。mutationは未サポート)。

ここではRQ自体の詳細は省略しますが、上記のページネーション(キャッシュあり)の例をjotai/queryを使って実装した物が以下になります。

https://github.com/tell-y/jotai-training/tree/main/jotai-async-atom-example-01-react-query

この例では、今までと同様にURLが変わる度にfetchが走り&URLをキーとしてキャッシュを実現しています。追加としては、キャッシュの有効を5秒のみとし、期限が切れていると裏でrefetchして完了後に古いデータを置き換えてくれます。(staleTimeの記述がこれに該当します)

atom.ts
import { atom } from "jotai";
import { atomWithQuery } from "jotai/query";

import { fetchPeople } from "./api";

export const peopleUrlAtom = atom<string>(
  "https://swapi.dev/api/people/?page=1"
);

export const peopleAtom = atomWithQuery((get) => ({
  queryKey: [get(peopleUrlAtom)],
  queryFn: async () => {
    const {
      results: people,
      next: nextUrl,
      previous: previousUrl
    } = await fetchPeople(get(peopleUrlAtom));
    const time = new Date();
    return {
      people,
      nextUrl,
      previousUrl,
      fetchedAt: `${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}`
    };
  },
  staleTime: 5000
}));

実際に触ってみてもらうと分かりやすいと思いますが、Fetched at XX:XX:XX と書かれている時間に注目してみてください。ページを行き来していると5秒以上たったページでは時刻が変化していることがわかるかと思います。

Jotaiでデータフェッチ処理を扱いたい場合、”現時点では”、jotai/queryをおすすめします。RQのhooksで使うのではなく、です。
そもそもですが、jotaiのatomと他のライブラリ(hooks)を連携するのは苦手なのです。atomの定義はコンポーネントの外で出来るのに対し、hooksと絡めるとなると一度コンポーネントを経由しなければならず正しくいきません。RQはQueryClientをReactの外に持つので、jotaiのatomと素直に連携できます。(正確には、tanstack/react-queryではなくtanstack/query-core にのみ依存しています)
今回のqueryKey: [get(peopleUrlAtom)]のように、他のatomと簡単に絡めることが出来るようになります。

Suspenseを使わない場合

ここまで、Async Atomの利用はSuspenseとセットで紹介してきましたが、何かしらの理由でSuspenseが使えないor使いたくない場合があるかもしれません。
その場合、utilsから提供されるloadableを使えます。
Async atomをloadable関数でラップしてloadable atomを作ります。

import { loadable } from "jotai/utils"

const asyncAtom = atom(async (get) => ...);
const loadableAtom = loadable(asyncAtom);

// ComponentはSuspenseで囲む必要は無し
const Component = () => {
  const { state, data, error } = useAtom(loadableAtom);
};

stateは3つの状態をもち、正しくPromiseが解決された時に元々のasync atomが返す値がdataへと格納されます。よく見る形ですね。

{
    state: 'loading' | 'hasData' | 'hasError',
    data?: any,
    error?: any,
}

Async atomの注意と改善方法

とあるコンポーネント内でAsync atomが複数あり、それらがまだマウントされていないとします。このような場合にコンポーネント(atoms)がマウントされた際、Promiseの解決が複数起こることになります。
注意していただきたいことは、これらのPromiseの解決は、順次行われるということです。そのため、処理時間が単純増加します。例え依存しあっていないatom同士であってもです。

上記の問題を改善する為には、並列処理が必要です。並列にPromiseを解決する為には、utilsから提供されるwaitForAllが使えます。

const dogsAtom = atom(async (get) => {
  const response = await fetch('/dogs');
  return await response.json();
});
const catsAtom = atom(async (get) => {
  const response = await fetch('/cats');
  return await response.json();
});

// dogsAtom, catsAtomの順にPromiseが解決される
const ComponentA = () => {
  const [dogs] = useAtom(dogsAtom);
  const [cats] = useAtom(catsAtom);
  ...
};

// waitForAllによって並列処理が可能
const ComponentB = () => {
  const [[dogs, cats]] = useAtom(waitForAll([dogsAtom, catsAtom]))
  ...
};

waitForAllを直接使ってもよいですが、コンポーネント(レンダー関数)内で扱う場合は無限ループ対策にuseMemoを使うなど気をつける点があります。
おすすめは、jotai-suspenseのusePrepareAtomsです。waitForAllを使ったシンプルなhooksとして提供されています。

https://github.com/jotai-labs/jotai-suspense/blob/42004614594493ed78e53bdae160eb53c3ceb3ba/src/usePrepareAtoms.ts#L8-L24

その名の通り、並列処理させておきたい(準備しておきたい)atomsを事前にマウントさせて使います。上記のdogsAtomとcatsAtomを使ったComponentAをマウントする前に以下のようにすると良いでしょう。

const Prepare = () => {
  usePrepareAtoms([dogsAtom, catsAtom]);
  return null;
};
const Components = () => (
  <>
    <Prepare />
    <ComponentA />
  </>
);

実際の挙動は以下のようになります。

Atomのwrite関数をasyncで書く

ここまで、Read-Only atomとしてasync atomについて紹介してきましたが、write関数もasync関数として定義可能です。

Atomのwrite関数のおさらい

atom関数の第2引数がwrite関数です。write関数では何をやるにも自由なので、サイドエフェクトが許されます。

const priceAtom = atom(1000);

// read-only atom
const priceWithTaxAtom = atom((get) => get(priceAtom) * 1.1);

// write-only atom : read関数が無いのでatomとして値を持たず何かの処理をするatom
const discountAtom = atom(
  null, // write only atomの場合は第1引数にnullを渡す
  (get, set, discountPrice: number) => {
    set(priceAtom, get(priceAtom) - discount);
  }
);

const discountValAtom = atom(0);
// read-write atom : 値の持たせ方(read)も処理(write)も自由
const priceWithDiscountAtom = atom(
  (get) => ({ price: get(priceAtom), discounted: get(priceAtom) - get(discountValAtom) }),
  (get, set, param: { newPrice: number; discount: number }) => {
    set(priceAtom, p.newPrice);
    set(discountValAtom, p.discount);
  }
);

atomの値を使った非同期処理の関数はAsync Write Atomへ

説明は不必要かと思いますが、以下のように出来ます。

const paramsAtom = atom(...);
const postStatusAtom = atom({ requesting: false, error: null });
const asyncPostAtom = atom(
  (get) => get(postStatusAtom),
  async (get, set) => {
    set(postStatusAtom, { requesting: true, error: null });
    const result = await post(get(params));
    set(postStatusAtom, { requesting: false, error: result.error });
});

const Component = () => {
  const [{ requesting, error }, post] = useAtom(asyncPostAtom);
  ...
  return (
    ...
    <button onClick={async () => { await post() }} disabled={!requesting}>
    ...
  );
};

注意点として、

  • write関数内でAsync atomを呼ぶ場合は事前にPromiseが解決されていること
  • write関数は戻り値を返せない(write関数共通の注意)

の2点があります。

おわりに

Jotaiでの非同期処理に関しての基本を紹介させていただきました。これらを押さえておけば、慣れはあるかもしれませんが、やりたい事は出来るのではと思います。

公式ドキュメントのAsyncページのAsync sometimesAsync foreverPersistenceページ、utilsのatomWithObservableabortableAtomなどなど、この記事では触れなかった点もあるので是非ご覧ください。

インテグレーションではjotai/queryを紹介しましたが、その他にもjotai/urqljotai-labsで紹介されているものもありますので合わせてチェックしておくと良いかと思います。

リンク集

Jotai普及活動をしています。よろしくおねがいします。
https://jotaifriends.dev/
https://twitter.com/jotaifriends
https://twitter.com/tell_y_t

Jotai関連記事
https://zenn.dev/tell_y

公式
https://jotai.org/
https://twitter.com/jotaijs
https://github.com/jotai-labs/links

Jotai作者
https://twitter.com/dai_shi

React Fanというコミュニティ(Slack)にいます。(Jotai作者のdai_shiさんが運営)
https://react-fan.axlight.com/index.svg

Jotai Friends

Discussion