jotai-eager で Promise<T> を T として扱う async sometimes パターン実装

に公開

React アプリケーションで利用できる状態管理ライブラリである Jotai ですが、筆者も技術的な特性がフィットする一部のプロジェクトで利用しています。

https://speakerdeck.com/izumin5210/number-newt-techtalk-vol-15

https://speakerdeck.com/izumin5210/designing-jotai-state-for-complex-forms-number-layerx-frontend

https://speakerdeck.com/izumin5210/number-tskaigi2025

Jotai v2 からは非同期処理がより柔軟に扱えるようになったことで、データフェッチやバックエンドでのバリデーションなどを伴う複雑な state であっても Promise<T>を返すだけの通常の atom として記述できるようになりました。

一方で、React コンポーネントで atom の値を読み込む useAtomValue は、atom が Prromise を返すと Suspend します。この Promise は内部的には解決済みで一瞬で返ってくるはずが、React 19 では0.3秒待たされてしまう… それに対処するのが "async sometimes" パターンです(詳細は下記の uhyo さんの記事を参考にしてください)。

https://zenn.dev/uhyo/articles/jotai-v2-async-sometimes

"async sometimes" パターンによって、非同期 atom を状態(atom)の依存関係グラフに自然に組み入れつつ、ユーザ体験も損ねないような実装が可能になります。

この "async sometimes" パターンを簡潔に記述するためのユーティリティとして jotai-derive があるのですが、最近これが jotai-eager とリネームされ新しい API が提供されるようになりました。

"async sometimes" パターン おさらい

「async sometimes」パターンとは、atom の値の型を Promise<T> ではなく Promise<T> | T とし、以下のような挙動を実現するパターンです:

  • 非同期処理が必要な場合: Promise<T> を返す(例:初回のデータフェッチ)
  • 同期的に計算可能な場合: T を直接返す(例:キャッシュされたデータの参照)

これにより、不要な Suspense を回避しつつ、必要な場合のみ非同期処理を行うことができます。

// ❌ 常に Promise を返す(毎回 Suspense が発生)
const badAtom = atom(async (get) => {
  const data = await get(queryAtom);
  return data.items;
});

// ✅ async sometimes パターン
// (queryAtom が解決済みでキャッシュがあれば Promise ではなく値が返る)
const goodAtom = atom((get) => {
  const data = get(queryAtom);
  if (data instanceof Promise) {
    return data.then(d => d.items);
  }
  return data.items;
});

いままでの jotai-derive

async sometimes パターンを実装するためのユーティリティライブラリとして、従来は jotai-derive が提供されていました。

jotai-derive が提供する derive を使うことで、単純に Promise の中身を変換するようなケースでは非常に簡潔に記述することができます。

import { derive } from "jotai-derive";

// 単純なケース
export const itemCountAtom = derive(
  [queryAtom],
  // queryAtom は Promise<T> を返す可能性があるが、
  // derive が内部でいい感じに処理して T にしてくれる
  (data) => data.items.length,
);

しかし、複雑なケース、たとえば何らかの条件によって非同期 atom の呼び出しをスキップしたいなどは derive では対応できず、 soon / soonAll などを使ったクセのあるコードを書く必要がありました。

import { soon, soonAll } from "jotai-derive";

// 条件を伴うケース
const restrictedItemAtom = atom((get) => {
  // 条件を満たさないときはそもそも非同期 atom を読みたくない
  if (!get(isAdminAtom)) return [];

  return soon(
    soonAll([get(queryAtom)]),
    ([data]) => {
      // ...
    },
  );
});

あたらしい jotai-eager

jotai-eager は jotai-derive をリネーム・改良したライブラリで、新たに eagerAtom という API を提供します。

eargerAtom は一見すると通常の read-only atom と同じような書き方ですが、「get が非同期 atom Atom<Promise<T>> を受け取って T を返すことができる」というのが大きな違いです。非同期 atom から同期的に値を取り出すことができるので、同期・非同期の区別によってコードが複雑にならず、簡潔に記述できるようになります。

import { eagerAtom } from "jotai-eager";

const itemsCountAtom = eagerAtom((get) => {
  // queryAtom が非同期でも同期的っぽく書ける
  const data = get(queryAtom);
  return data.items.length;
});

eagerAtom を使えば read 関数内で自然に非同期 atom から get できるため、jotai-derive であれば soon / soonAll が必要なケースでも簡潔に記述することができます。

const restrictedItemAtom = eagerAtom((get) => {
  if (get(isAdminAtom)) return [];

  // queryAtom は非同期 atom `Atom<T | Promise<T>>` だが、
  // data は `T` となる
  const data = get(queryAtom);

  // ...
});

eagerAtom はどうやって Promise を剥がしているか

getPromise<T> を渡すと await なしで T が返ってくる」… 文字にすると魔術的ですが、これをどのように実現しているか。コアは200行にも満たないシンプルな実装になっています。

get 内で取得した Promise が pending であれば一旦 throw し、

https://github.com/jotaijs/jotai-eager/blob/v0.2.2/packages/jotai-eager/src/eagerAtom.ts#L53-L55

それを親で catch して、Promise が解決したら再度 read 関数(eagerAtom にわたす関数)を呼び出すという形になっています。(下記のコードの compute が read 関数に相当)

https://github.com/jotaijs/jotai-eager/blob/v0.2.2/packages/jotai-eager/src/eagerAtom.ts#L73-L80

React っぽいですね。 Promise は投げるもの。

まとめ

jotai-eager の eagerAtom を使うことで、完全にではないものの、非同期処理をより透過的・簡潔に記述できるようになります。 この記事では紹介しませんでしたが、Promise.all に相当する get.all などのユーティリティも提供されており、シンプルながらも最低限の機能は揃っていそうです。

atom()eagerAtom()get()get.all() などの使い分けなど、非同期処理を全く意識しなくていいかというとそういうわけではないですが、「データ依存グラフの中に自然に非同期を溶け込ませる」というのをわりと高いレベルで達成できているように思えます。

React エコシステム全体としてはそもそも fulfilled な Promise を suspend させない方向・async sometimes とか考えなくていい方向に行ってほしい気もしますが、現状では atom 単位で T | Promise<T> にする・それをやりやすくするのに eagerAtom を活用する、というのは現実的にコスパの良い解決策なんじゃないでしょうか。

LayerX

Discussion